diff --git a/README.md b/README.md index b507125..c8c3893 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,12 @@ CodingTestKit was built to **replicate the real exam environment inside your IDE | Python | O | O | O | O | | C++ | O | O | O | O | | Kotlin | O | X | O | O | +| JavaScript | O | X | O | O | +| Rust | X | X | O | O | +| Go | O | X | O | O | +| Ruby | O | X | O | O | + +> X = submission not supported on that platform. Local test execution works for all languages (compiler/runtime must be installed: JDK, python3, g++, kotlinc, Node.js, rustc, go, ruby). Selecting an unsupported combination shows a warning next to the platform/language selectors immediately. --- @@ -83,12 +89,13 @@ CodingTestKit was built to **replicate the real exam environment inside your IDE | 4 | [**Login & Submit**](docs/features/submit.md) | Submit code directly via built-in browser with auto language selection | | 5 | [**Problem Search**](docs/features/search.md) | Search problems on LeetCode, Codeforces, Programmers, and SWEA | | 6 | [**Random Problem Picker**](docs/features/random.md) | Pick random problems with tier/difficulty/tag filters | -| 7 | [**Code Editor**](#code-editor) | Auto-generated boilerplate code per platform & language | -| 8 | [**Code Templates**](docs/features/templates.md) | Save & reuse frequently used code snippets | -| 9 | [**Timer**](docs/features/timer.md) | Stopwatch with laps + countdown with circular dial, progress bar, digital clock | -| 10 | [**Settings & Exam Mode**](docs/features/exam-mode.md) | One-click exam mode: block paste, disable autocomplete, focus alert | -| 11 | [**GitHub Integration**](docs/features/github.md) | Auto-push accepted solutions to GitHub | -| 12 | [**Internationalization**](#internationalization-i18n) | Full Korean / English UI support | +| 7 | **My Solved** | List your solved problems on LeetCode & Codeforces | +| 8 | [**Code Editor**](#code-editor) | Auto-generated boilerplate code per platform & language | +| 9 | [**Code Templates**](docs/features/templates.md) | Save & reuse frequently used code snippets | +| 10 | [**Timer**](docs/features/timer.md) | Stopwatch with laps + countdown with circular dial, progress bar, digital clock | +| 11 | [**Settings & Exam Mode**](docs/features/exam-mode.md) | One-click exam mode: block paste, disable autocomplete, focus alert | +| 12 | [**GitHub Integration**](docs/features/github.md) | Auto-push accepted solutions to GitHub | +| 13 | [**Internationalization**](#internationalization-i18n) | Full Korean / English UI support | --- @@ -101,6 +108,8 @@ Select the platform and language, enter a problem number, and the problem descri - **LeetCode**: Enter number, slug, or URL (e.g., `1`, `two-sum`, full URL) - **Codeforces**: contestId+letter (e.g., `1234A`) or URL +> Codeforces: if Cloudflare blocks the request, fetching automatically falls back to the built-in JCEF browser (may take ~5–20 seconds longer). +

Fetch, Test & Submit

@@ -122,7 +131,7 @@ When a problem is fetched, a folder is automatically created with a code file an ### Problem View & Translation

- Fetch Example + Fetch Example

View the problem description, I/O format, and examples directly in the plugin panel. @@ -193,19 +202,11 @@ Programmers and LeetCode solution functions are automatically wrapped for testin Log in to each platform via the built-in JCEF browser and submit your code directly. The language dropdown is **automatically selected** to match your code.

- Platform Login + Fetch → Test → Submit

Click **Submit**, confirm the dialog, and your code & language are auto-filled. -

- Submit Confirmation -

- -

- Code Auto-Fill -

-

Back to Features

--- @@ -297,11 +298,12 @@ Provides a **Stopwatch** and a **Countdown Timer**. - **Auto Complete ON/OFF**: Toggle code auto-completion popups - **Inspections ON/OFF**: Enable power save mode to stop background analysis +- **Code Vision ON/OFF**: Hide "N usages" and other Code Vision hints in the editor - **Paste Block**: Block pasting text copied from external programs - **Focus Alert**: Show alert when IDE window loses focus - **Language**: Switch between Korean / English -One-click **Exam Mode** enables all 4 restrictions; **Normal Mode** disables them all. +One-click **Exam Mode** enables all 5 restrictions; **Normal Mode** disables them all.

Settings @@ -357,8 +359,8 @@ Switch between **Korean / English** in settings. All UI text is displayed in the ## Requirements -- IntelliJ IDEA 2024.1+ -- JDK 17+ (for Java execution) +- IntelliJ IDEA 2024.3+ +- JDK 17+ (for Java execution) — building the plugin from source requires JDK 21 - Language compilers (for respective language tests) ## Build @@ -409,6 +411,12 @@ CodingTestKit은 **실제 시험 환경을 IDE 안에서 그대로 재현**하 | Python | O | O | O | O | | C++ | O | O | O | O | | Kotlin | O | X | O | O | +| JavaScript | O | X | O | O | +| Rust | X | X | O | O | +| Go | O | X | O | O | +| Ruby | O | X | O | O | + +> X = 해당 플랫폼 제출 미지원. 로컬 테스트 실행은 모든 언어 지원 (컴파일러/런타임 설치 필요: JDK, python3, g++, kotlinc, Node.js, rustc, go, ruby). 제출 미지원 조합을 선택하면 플랫폼/언어 콤보 옆에 즉시 경고가 표시됩니다. --- @@ -424,12 +432,13 @@ CodingTestKit은 **실제 시험 환경을 IDE 안에서 그대로 재현**하 | 4 | [**로그인 & 제출**](docs/features/submit.md) | 내장 브라우저로 코드 제출 + 언어 자동 선택 | | 5 | [**문제 검색**](docs/features/search.md) | LeetCode, Codeforces, 프로그래머스, SWEA 문제 검색 | | 6 | [**랜덤 문제 뽑기**](docs/features/random.md) | 티어/난이도/태그 필터로 랜덤 문제 추천 | -| 7 | [**코드 에디터**](#코드-에디터) | 플랫폼 & 언어별 보일러플레이트 코드 자동 생성 | -| 8 | [**코드 템플릿**](docs/features/templates.md) | 자주 쓰는 코드 스니펫 저장 & 재사용 | -| 9 | [**타이머**](docs/features/timer.md) | 스톱워치 + 원형 다이얼/프로그레스 바/디지털 시계 카운트다운 | -| 10 | [**설정 & 시험 모드**](docs/features/exam-mode.md) | 원클릭 시험 모드: 붙여넣기 차단, 자동완성 끄기, 포커스 감지 | -| 11 | [**GitHub 연동**](docs/features/github.md) | 맞은 문제 자동 GitHub 푸시 | -| 12 | [**다국어 지원**](#다국어-지원-i18n) | 한국어 / English UI 완전 지원 | +| 7 | **내 풀이** | LeetCode & Codeforces에서 내가 푼 문제 목록 확인 | +| 8 | [**코드 에디터**](#코드-에디터) | 플랫폼 & 언어별 보일러플레이트 코드 자동 생성 | +| 9 | [**코드 템플릿**](docs/features/templates.md) | 자주 쓰는 코드 스니펫 저장 & 재사용 | +| 10 | [**타이머**](docs/features/timer.md) | 스톱워치 + 원형 다이얼/프로그레스 바/디지털 시계 카운트다운 | +| 11 | [**설정 & 시험 모드**](docs/features/exam-mode.md) | 원클릭 시험 모드: 붙여넣기 차단, 자동완성 끄기, 포커스 감지 | +| 12 | [**GitHub 연동**](docs/features/github.md) | 맞은 문제 자동 GitHub 푸시 | +| 13 | [**다국어 지원**](#다국어-지원-i18n) | 한국어 / English UI 완전 지원 | --- @@ -442,6 +451,8 @@ CodingTestKit은 **실제 시험 환경을 IDE 안에서 그대로 재현**하 - **LeetCode**: 문제 번호, slug, 또는 URL 입력 (예: `1`, `two-sum`, URL) - **Codeforces**: 콘테스트번호+문제번호 (예: `1234A`) 또는 URL +> Codeforces: Cloudflare가 요청을 차단하면 내장 JCEF 브라우저로 자동 폴백합니다 (약 5~20초 더 걸릴 수 있음). +

문제 가져오기 & 테스트 & 제출

@@ -463,7 +474,7 @@ CodingTestKit은 **실제 시험 환경을 IDE 안에서 그대로 재현**하 ### 문제 보기 & 번역

- 문제 가져오기 예시 + 문제 가져오기 예시

플러그인 패널에서 문제 설명, 입출력 형식, 예제를 바로 확인할 수 있습니다. @@ -534,19 +545,11 @@ FAIL인 케이스는 빨간색으로 표시되어 한눈에 확인할 수 있습 내장 JCEF 브라우저를 통해 각 플랫폼에 로그인하고, 코드를 직접 제출할 수 있습니다. 제출 시 코드 작성 언어에 맞춰 **언어 드롭다운이 자동 선택**됩니다.

- 플랫폼 로그인 + 가져오기 → 테스트 → 제출

**제출** 버튼을 누르면 제출 확인 다이얼로그가 표시되고, 코드와 언어가 자동으로 입력됩니다. -

- 제출 확인 -

- -

- 코드 자동 입력 -

-

기능 목록으로

--- @@ -638,11 +641,12 @@ FAIL인 케이스는 빨간색으로 표시되어 한눈에 확인할 수 있습 - **자동완성 ON/OFF**: 코드 자동완성 팝업을 끄고 켤 수 있습니다 - **코드 검사 ON/OFF**: 절전 모드를 활성화하여 백그라운드 분석을 중지합니다 +- **사용 위치 힌트 끄기 (Code Vision)**: 에디터의 'N개 사용 위치' 등 Code Vision 힌트를 숨깁니다 - **외부 붙여넣기 차단**: 외부 프로그램에서 복사한 텍스트의 붙여넣기를 차단합니다 - **포커스 이탈 감지**: IDE 창에서 포커스가 벗어나면 경고를 표시합니다 - **언어 설정**: 한국어 / English 전환 가능 -**시험 모드** 버튼을 누르면 4가지 설정이 한 번에 적용되고, **일반 모드** 버튼을 누르면 모두 해제됩니다. +**시험 모드** 버튼을 누르면 5가지 설정이 한 번에 적용되고, **일반 모드** 버튼을 누르면 모두 해제됩니다.

설정 @@ -698,8 +702,8 @@ FAIL인 케이스는 빨간색으로 표시되어 한눈에 확인할 수 있습 ## 요구 사항 -- IntelliJ IDEA 2024.1 이상 -- JDK 17 이상 (Java 실행용) +- IntelliJ IDEA 2024.3 이상 +- JDK 17 이상 (Java 실행용) — 플러그인을 소스에서 직접 빌드하려면 JDK 21 필요 - 각 언어 컴파일러 (해당 언어 테스트 시) ## 빌드 diff --git a/build.gradle.kts b/build.gradle.kts index 2c91449..c69761c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } group = "com.codingtestkit" -version = "1.4.4" +version = "1.5.0" repositories { mavenCentral() @@ -33,7 +33,7 @@ intellijPlatform { pluginConfiguration { id = "com.codingtestkit" name = "CodingTestKit" - version = "1.4.4" + version = "1.5.0" ideaVersion { sinceBuild = "243" untilBuild = provider { null } diff --git a/docs/README_en.md b/docs/README_en.md index 2247691..cfe952e 100644 --- a/docs/README_en.md +++ b/docs/README_en.md @@ -42,6 +42,12 @@ CodingTestKit was built to **replicate the real exam environment inside your IDE | Python | O | O | O | O | | C++ | O | O | O | O | | Kotlin | O | X | O | O | +| JavaScript | O | X | O | O | +| Rust | X | X | O | O | +| Go | O | X | O | O | +| Ruby | O | X | O | O | + +> X = submission not supported on that platform. Local test execution works for all languages (compiler/runtime must be installed: JDK, python3, g++, kotlinc, Node.js, rustc, go, ruby). Selecting an unsupported combination shows a warning next to the platform/language selectors immediately. --- @@ -49,7 +55,7 @@ CodingTestKit was built to **replicate the real exam environment inside your IDE ### Internationalization (i18n) -Switch between **Korean / English** in settings. All UI text is displayed in the selected language. The language change note is shown in both languages simultaneously. +Switch between **Korean / English** in settings. All UI text is displayed in the selected language. A note reminds you to reopen the tool window to apply changes. ### Fetch Problems @@ -60,6 +66,8 @@ Select the platform and language, enter a problem number, and the problem descri - **LeetCode**: Enter number, slug, or URL (e.g., `1`, `two-sum`, full URL) - **Codeforces**: contestId+letter (e.g., `1234A`) or URL +> Codeforces: if Cloudflare blocks the request, fetching automatically falls back to the built-in JCEF browser (may take ~5–20 seconds longer). +

Main Panel

@@ -82,7 +90,7 @@ When a problem is fetched, a folder is automatically created with a code file an ### Problem View & Translation

- Fetch Example + Fetch Example

View the problem description, I/O format, and examples directly in the plugin panel. @@ -146,11 +154,7 @@ Log in to each platform via the built-in JCEF browser and submit your code direc Click **Submit**, confirm the dialog, and your code & language are auto-filled.

- Submit Confirmation -

- -

- Code Auto-Fill + Fetch → Test → Submit

Login and submission work the same way for Programmers, SWEA, LeetCode, and Codeforces. @@ -227,11 +231,12 @@ Practice under conditions identical to real coding tests. - **Auto Complete ON/OFF**: Toggle code auto-completion popups - **Inspections ON/OFF**: Enable power save mode to stop background analysis +- **Code Vision ON/OFF**: Hide "N usages" and other Code Vision hints in the editor - **Paste Block**: Block pasting text copied from external programs (internal copy/paste works normally) - **Focus Alert**: Show alert when IDE window loses focus (same as cheating detection in real tests) -- **Language**: Switch between Korean / English (bilingual note) +- **Language**: Switch between Korean / English (a note reminds you to reopen the tool window to apply changes) -One-click **Exam Mode** enables all 4 restrictions; **Normal Mode** disables them all. +One-click **Exam Mode** enables all 5 restrictions; **Normal Mode** disables them all.

Settings @@ -321,8 +326,8 @@ Setup: Settings > GitHub Integration > Click "GitHub Login" and select your repo ## Requirements -- IntelliJ IDEA 2024.1+ -- JDK 17+ (for Java execution) +- IntelliJ IDEA 2024.3+ +- JDK 17+ (for Java execution) — building the plugin from source requires JDK 21 - Language compilers (for respective language tests) ## Build diff --git a/docs/README_ko.md b/docs/README_ko.md index 91b1786..7a87f89 100644 --- a/docs/README_ko.md +++ b/docs/README_ko.md @@ -44,6 +44,12 @@ CodingTestKit은 이런 **실제 시험 환경을 IDE 안에서 그대로 재현 | Python | O | O | O | O | | C++ | O | O | O | O | | Kotlin | O | X | O | O | +| JavaScript | O | X | O | O | +| Rust | X | X | O | O | +| Go | O | X | O | O | +| Ruby | O | X | O | O | + +> X = 해당 플랫폼 제출 미지원. 로컬 테스트 실행은 모든 언어 지원 (컴파일러/런타임 설치 필요: JDK, python3, g++, kotlinc, Node.js, rustc, go, ruby). 제출 미지원 조합을 선택하면 플랫폼/언어 콤보 옆에 즉시 경고가 표시됩니다. --- @@ -51,7 +57,7 @@ CodingTestKit은 이런 **실제 시험 환경을 IDE 안에서 그대로 재현 ### 다국어 지원 (i18n) -설정에서 **한국어 / English** 전환이 가능합니다. 모든 UI 텍스트가 선택한 언어로 표시됩니다. 언어 변경 안내문은 한·영 동시에 표시됩니다. +설정에서 **한국어 / English** 전환이 가능합니다. 모든 UI 텍스트가 선택한 언어로 표시됩니다. 언어 변경 안내문은 선택된 언어로 표시되며, 툴 윈도우를 다시 열면 적용됩니다. ### 문제 가져오기 @@ -62,6 +68,8 @@ CodingTestKit은 이런 **실제 시험 환경을 IDE 안에서 그대로 재현 - **LeetCode**: 문제 번호, slug, 또는 URL 입력 (예: `1`, `two-sum`, URL) - **Codeforces**: 콘테스트번호+문제번호 (예: `1234A`) 또는 URL +> Codeforces: Cloudflare가 요청을 차단하면 내장 JCEF 브라우저로 자동 폴백합니다 (약 5~20초 더 걸릴 수 있음). +

메인 패널

@@ -84,7 +92,7 @@ CodingTestKit은 이런 **실제 시험 환경을 IDE 안에서 그대로 재현 ### 문제 보기 & 번역

- 문제 가져오기 예시 + 문제 가져오기 예시

플러그인 패널에서 문제 설명, 입출력 형식, 예제를 바로 확인할 수 있습니다. @@ -147,6 +155,10 @@ LeetCode 문제를 키워드, 난이도, 알고리즘 태그로 검색합니다. **제출** 버튼을 누르면 내장 브라우저에서 제출 페이지가 열리고, 코드와 언어가 자동으로 입력됩니다. 사용자는 스크롤만 내려서 **제출** 버튼을 누르면 끝입니다. +

+ 가져오기 → 테스트 → 제출 +

+ **프로그래머스**, **SWEA**, **LeetCode**, **Codeforces** 모두 동일한 방식으로 로그인과 제출이 가능합니다. ### 로컬 테스트 실행 @@ -221,11 +233,12 @@ FAIL인 케이스는 빨간색으로 표시되어 한눈에 확인할 수 있습 - **자동완성 ON/OFF**: 코드 자동완성 팝업을 끄고 켤 수 있습니다 - **코드 검사 ON/OFF**: 절전 모드를 활성화하여 백그라운드 분석을 중지합니다 +- **사용 위치 힌트 끄기 (Code Vision)**: 에디터의 'N개 사용 위치' 등 Code Vision 힌트를 숨깁니다 - **외부 붙여넣기 차단**: 외부 프로그램에서 복사한 텍스트의 붙여넣기를 차단합니다 - **포커스 이탈 감지**: IDE 창에서 포커스가 벗어나면 경고를 표시합니다 -- **언어 설정**: 한국어 / English 전환 가능 (안내문 한·영 동시 표시) +- **언어 설정**: 한국어 / English 전환 가능 (안내문은 선택된 언어로 표시) -**시험 모드** 버튼을 누르면 4가지 설정이 한 번에 적용되고, **일반 모드** 버튼을 누르면 모두 해제됩니다. +**시험 모드** 버튼을 누르면 5가지 설정이 한 번에 적용되고, **일반 모드** 버튼을 누르면 모두 해제됩니다.

설정 @@ -315,8 +328,8 @@ FAIL인 케이스는 빨간색으로 표시되어 한눈에 확인할 수 있습 ## 요구 사항 -- IntelliJ IDEA 2024.1 이상 -- JDK 17 이상 (Java 실행용) +- IntelliJ IDEA 2024.3 이상 +- JDK 17 이상 (Java 실행용) — 플러그인을 소스에서 직접 빌드하려면 JDK 21 필요 - 각 언어 컴파일러 (해당 언어 테스트 시) ## 빌드 diff --git a/docs/features/exam-mode.md b/docs/features/exam-mode.md index 06630b3..743740d 100644 --- a/docs/features/exam-mode.md +++ b/docs/features/exam-mode.md @@ -16,10 +16,11 @@ Simulate real coding test environments by restricting IDE features. |---------|-------------| | **Auto Complete OFF** | Disables code auto-completion popups | | **Inspections OFF** | Enables power save mode to stop background analysis | +| **Code Vision OFF** | Hides "N usages" and other Code Vision hints in the editor | | **Paste Block** | Blocks pasting text copied from external programs | | **Focus Alert** | Shows alert when IDE window loses focus | -One-click **Exam Mode** enables all 4 restrictions simultaneously. +One-click **Exam Mode** enables all 5 restrictions simultaneously. One-click **Normal Mode** disables them all.

@@ -52,10 +53,11 @@ One-click **Normal Mode** disables them all. |------|------| | **자동완성 OFF** | 코드 자동완성 팝업을 끕니다 | | **코드 검사 OFF** | 절전 모드를 활성화하여 백그라운드 분석을 중지합니다 | +| **사용 위치 힌트 끄기 (Code Vision OFF)** | 에디터의 'N개 사용 위치' 등 Code Vision 힌트를 숨깁니다 | | **외부 붙여넣기 차단** | 외부 프로그램에서 복사한 텍스트의 붙여넣기를 차단합니다 | | **포커스 이탈 감지** | IDE 창에서 포커스가 벗어나면 경고를 표시합니다 | -**시험 모드** 버튼 → 4가지 설정 한 번에 적용 +**시험 모드** 버튼 → 5가지 설정 한 번에 적용 **일반 모드** 버튼 → 모두 해제 ### 기타 설정 diff --git a/docs/features/fetch.md b/docs/features/fetch.md index 3fde438..2115b64 100644 --- a/docs/features/fetch.md +++ b/docs/features/fetch.md @@ -21,8 +21,8 @@ Select the platform and language, enter a problem number, and the problem descri ### What Happens After Fetch -1. A **folder** is created under your project: `[Platform] ProblemNumber - Title/` -2. A **code file** with boilerplate is generated (e.g., `Main.java`, `solution.py`) +1. A **folder** is created under your project: `problems/{platform}/{difficulty}/{id}. {title}/` (e.g., `problems/LeetCode/Medium/1. Two Sum/`; `Unrated` is used when the difficulty is unknown) +2. A **code file** with boilerplate is generated — `Solution.java` / `Solution.py` etc. for Programmers, SWEA, and LeetCode; `Main.*` for Codeforces 3. A **README.md** with the problem description is optionally created (toggle in Settings) 4. **Test cases** are automatically extracted and ready to run @@ -55,6 +55,11 @@ Select the platform and language, enter a problem number, and the problem descri - Extracts code snippets, example test cases, and metadata - Supports number (`1`), slug (`two-sum`), and full URL input +#### Codeforces +- Fetches via HTTP crawling first; if Cloudflare blocks the request (e.g., 403), automatically falls back to the built-in offscreen JCEF browser, which passes the challenge and extracts the page +- When the fallback engages, fetching may take about 5–20 seconds longer +- Requires an IDE with JCEF support (all recent IntelliJ versions) + --- ## 한국어 @@ -72,8 +77,8 @@ Select the platform and language, enter a problem number, and the problem descri ### 가져오기 후 결과 -1. 프로젝트 내에 **폴더**가 생성됩니다: `[플랫폼] 문제번호 - 제목/` -2. 보일러플레이트가 포함된 **코드 파일**이 생성됩니다 (예: `Main.java`, `solution.py`) +1. 프로젝트 내에 **폴더**가 생성됩니다: `problems/{플랫폼}/{난이도}/{문제번호}. {제목}/` (예: `problems/LeetCode/Medium/1. Two Sum/`, 난이도를 알 수 없으면 `Unrated`) +2. 보일러플레이트가 포함된 **코드 파일**이 생성됩니다 — 프로그래머스·SWEA·LeetCode는 `Solution.java` / `Solution.py` 등, Codeforces는 `Main.*` 3. 문제 설명이 담긴 **README.md**가 선택적으로 생성됩니다 (설정에서 토글 가능) 4. **테스트 케이스**가 자동 추출되어 바로 실행 가능합니다 @@ -93,3 +98,8 @@ Select the platform and language, enter a problem number, and the problem descri - GraphQL API로 가져옴 - 코드 스니펫, 예제 테스트 케이스, 메타데이터 추출 - 번호(`1`), slug(`two-sum`), 전체 URL 입력 지원 + +#### Codeforces +- HTTP 크롤링으로 먼저 가져오고, Cloudflare에 차단(403 등)되면 내장 오프스크린 JCEF 브라우저로 자동 폴백하여 챌린지를 통과한 뒤 페이지를 추출 +- 폴백이 동작하면 가져오기가 약 5~20초 더 걸릴 수 있음 +- JCEF를 지원하는 IDE 필요 (최근 IntelliJ 버전은 모두 지원) diff --git a/docs/features/submit.md b/docs/features/submit.md index ecc3b3a..73d2c9b 100644 --- a/docs/features/submit.md +++ b/docs/features/submit.md @@ -15,7 +15,7 @@ Log in to each platform via the built-in JCEF browser and submit your code direc Click the **Login** button for any platform. A built-in browser opens the platform's login page.

- Platform Login + Fetch → Test → Submit

### Submit Flow @@ -26,13 +26,13 @@ Click the **Login** button for any platform. A built-in browser opens the platfo 4. **Language** dropdown is automatically selected to match your code 5. Verify and click the platform's submit button -

- Submit Confirmation -

+### Unsupported Combination Warning -

- Code Auto-Fill -

+Some (platform, language) combinations cannot be submitted (e.g., Rust on Programmers; Kotlin, JavaScript, Rust, Go, or Ruby on SWEA). When you select such a combination, a warning appears next to the platform/language selectors immediately: + +> ⚠ Rust — cannot submit to Programmers (local testing only) + +Local test execution still works for every combination — only submission is restricted. ### Auto Language Selection @@ -75,6 +75,14 @@ After submitting, the plugin monitors the page for verdict: 4. **언어** 드롭다운이 코드 작성 언어에 맞춰 자동 선택 5. 확인 후 플랫폼의 제출 버튼 클릭 +### 제출 미지원 조합 경고 + +일부 (플랫폼, 언어) 조합은 제출이 불가능합니다 (예: 프로그래머스 × Rust, SWEA × Kotlin/JavaScript/Rust/Go/Ruby). 이런 조합을 선택하면 플랫폼/언어 콤보 옆에 즉시 경고가 표시됩니다: + +> ⚠ Rust — 프로그래머스 제출 미지원 (로컬 테스트만 가능) + +로컬 테스트 실행은 모든 조합에서 가능하며, 제출만 제한됩니다. + ### 언어 자동 선택 모든 플랫폼에서 언어 드롭다운이 자동 설정됩니다: diff --git a/docs/features/test.md b/docs/features/test.md index cc95369..48102b0 100644 --- a/docs/features/test.md +++ b/docs/features/test.md @@ -47,6 +47,10 @@ Failed cases are highlighted in red and auto-expanded for easy debugging. Programmers Test

+### Language Selection + +The language selector in the Tests tab is synchronized with the one in the Problems tab — changing either one automatically updates the other. + ### Supported Languages | Language | Compiler/Runtime | Notes | @@ -56,6 +60,9 @@ Failed cases are highlighted in red and auto-expanded for easy debugging. | C++ | `g++` | C++17 standard | | Kotlin | `kotlinc` + `kotlin` | JVM-based execution | | JavaScript | `node` | Node.js runtime | +| Rust | `rustc` | Install via rustup — auto-detects `~/.cargo/bin` | +| Go | `go` | Install from go.dev/dl — auto-detects `/usr/local/go/bin`, `/opt/homebrew` | +| Ruby | `ruby` | Install via `brew install ruby` or rbenv — auto-detects `/opt/homebrew`, rbenv shims | ### Comparison Logic @@ -87,6 +94,10 @@ Failed cases are highlighted in red and auto-expanded for easy debugging. - 클래스 기반(LeetCode)과 순수 함수(프로그래머스) 패턴 자동 감지 - 각 언어에 맞는 드라이버 코드 자동 생성 +### 언어 선택 + +테스트 탭의 언어 콤보는 문제 탭의 언어 콤보와 양방향으로 동기화됩니다 — 한쪽에서 언어를 바꾸면 다른 쪽도 자동으로 바뀝니다. + ### 지원 언어 | 언어 | 컴파일러/런타임 | 비고 | @@ -96,6 +107,9 @@ Failed cases are highlighted in red and auto-expanded for easy debugging. | C++ | `g++` | C++17 표준 | | Kotlin | `kotlinc` + `kotlin` | JVM 기반 실행 | | JavaScript | `node` | Node.js 런타임 | +| Rust | `rustc` | rustup으로 설치 — `~/.cargo/bin` 자동 감지 | +| Go | `go` | go.dev/dl에서 설치 — `/usr/local/go/bin`, `/opt/homebrew` 자동 감지 | +| Ruby | `ruby` | `brew install ruby` 또는 rbenv로 설치 — `/opt/homebrew`, rbenv shims 자동 감지 | ### 비교 로직 diff --git a/docs/screenshots/boj-login.png b/docs/screenshots/boj-login.png deleted file mode 100644 index 5bf0add..0000000 Binary files a/docs/screenshots/boj-login.png and /dev/null differ diff --git a/docs/screenshots/boj-submit-code.png b/docs/screenshots/boj-submit-code.png deleted file mode 100644 index f405172..0000000 Binary files a/docs/screenshots/boj-submit-code.png and /dev/null differ diff --git a/docs/screenshots/boj-submit-confirm.png b/docs/screenshots/boj-submit-confirm.png deleted file mode 100644 index c776a32..0000000 Binary files a/docs/screenshots/boj-submit-confirm.png and /dev/null differ diff --git a/docs/screenshots/boj-submit-success.png b/docs/screenshots/boj-submit-success.png deleted file mode 100644 index a2bfa93..0000000 Binary files a/docs/screenshots/boj-submit-success.png and /dev/null differ diff --git a/src/main/kotlin/com/codingtestkit/model/Models.kt b/src/main/kotlin/com/codingtestkit/model/Models.kt index 28958ab..34716c5 100644 --- a/src/main/kotlin/com/codingtestkit/model/Models.kt +++ b/src/main/kotlin/com/codingtestkit/model/Models.kt @@ -58,7 +58,21 @@ enum class Language( PYTHON("Python", "py", 5), CPP("C++", "cpp", 1), KOTLIN("Kotlin", "kt", -1), - JAVASCRIPT("JavaScript", "js", -1); + JAVASCRIPT("JavaScript", "js", -1), + RUST("Rust", "rs", -1), + GO("Go", "go", -1), + RUBY("Ruby", "rb", -1); + + /** + * 해당 플랫폼에 코드를 제출할 수 있는 언어인지. + * 로컬 테스트 실행은 언어·플랫폼 무관 항상 가능하며, 제출만 플랫폼 제약을 받는다. + */ + fun isSubmittable(source: ProblemSource): Boolean = when (source) { + ProblemSource.PROGRAMMERS -> this != RUST // 프로그래머스는 Rust 채점 미지원 + ProblemSource.SWEA -> sweaId >= 0 + ProblemSource.LEETCODE -> true + ProblemSource.CODEFORCES -> true + } fun defaultCode(source: ProblemSource): String { return when (this) { @@ -186,6 +200,102 @@ enum class Language( |}); """.trimMargin() } + RUST -> when (source) { + ProblemSource.PROGRAMMERS -> """ + |fn solution() -> i32 { + | let answer = 0; + | answer + |} + """.trimMargin() + ProblemSource.SWEA -> """ + |use std::io::{self, BufRead}; + | + |fn main() { + | let stdin = io::stdin(); + | let mut lines = stdin.lock().lines(); + | let t: usize = lines.next().unwrap().unwrap().trim().parse().unwrap(); + | for tc in 1..=t { + | println!("#{} ", tc); + | } + |} + """.trimMargin() + ProblemSource.LEETCODE -> "" + ProblemSource.CODEFORCES -> """ + |use std::io::{self, Read}; + | + |fn main() { + | let mut input = String::new(); + | io::stdin().read_to_string(&mut input).unwrap(); + | let mut it = input.split_whitespace(); + | + |} + """.trimMargin() + } + GO -> when (source) { + ProblemSource.PROGRAMMERS -> """ + |func solution() int { + | answer := 0 + | return answer + |} + """.trimMargin() + ProblemSource.SWEA -> """ + |package main + | + |import ( + | "bufio" + | "fmt" + | "os" + |) + | + |func main() { + | reader := bufio.NewReader(os.Stdin) + | var T int + | fmt.Fscan(reader, &T) + | for tc := 1; tc <= T; tc++ { + | fmt.Printf("#%d \n", tc) + | } + |} + """.trimMargin() + ProblemSource.LEETCODE -> "" + ProblemSource.CODEFORCES -> """ + |package main + | + |import ( + | "bufio" + | "fmt" + | "os" + |) + | + |func main() { + | reader := bufio.NewReader(os.Stdin) + | writer := bufio.NewWriter(os.Stdout) + | defer writer.Flush() + | + | var n int + | fmt.Fscan(reader, &n) + | fmt.Fprintln(writer, n) + |} + """.trimMargin() + } + RUBY -> when (source) { + ProblemSource.PROGRAMMERS -> """ + |def solution() + | answer = 0 + | answer + |end + """.trimMargin() + ProblemSource.SWEA -> """ + |T = gets.to_i + |(1..T).each do |tc| + | puts "##{tc} " + |end + """.trimMargin() + ProblemSource.LEETCODE -> "" + ProblemSource.CODEFORCES -> """ + |lines = STDIN.read.split("\n") + | + """.trimMargin() + } } } } diff --git a/src/main/kotlin/com/codingtestkit/service/AuthService.kt b/src/main/kotlin/com/codingtestkit/service/AuthService.kt index 6ce717a..bf56a9c 100644 --- a/src/main/kotlin/com/codingtestkit/service/AuthService.kt +++ b/src/main/kotlin/com/codingtestkit/service/AuthService.kt @@ -33,11 +33,31 @@ class AuthService : PersistentStateComponent { private var authState = AuthState() + /** + * PasswordSafe(OS 키체인) 접근은 느려서 EDT에서 호출하면 SlowOperations 위반. + * 읽기는 메모리 캐시로 즉시 응답하고, 쓰기는 캐시 갱신 후 단일 스레드로 영속화한다 + * (단일 스레드 executor라 쓰기 순서가 보장됨). + */ + private val cookieCache = java.util.concurrent.ConcurrentHashMap() + private val persistExecutor = java.util.concurrent.Executors.newSingleThreadExecutor { r -> + Thread(r, "CodingTestKit-AuthPersist").apply { isDaemon = true } + } + + init { + // 캐시 예열: 이후 EDT에서의 readCookies가 키체인을 건드리지 않게 함 + persistExecutor.execute { + for (source in ProblemSource.entries) { + val stored = PasswordSafe.instance.getPassword(credentialAttributes(source)) ?: "" + cookieCache.putIfAbsent(source, stored) + } + } + } + override fun getState(): AuthState = authState override fun loadState(state: AuthState) { authState = state - migrateLegacyCookies() + persistExecutor.execute { migrateLegacyCookies() } } private fun migrateLegacyCookies() { @@ -63,11 +83,17 @@ class AuthService : PersistentStateComponent { CredentialAttributes(generateServiceName("CodingTestKit", "cookie.${source.name}")) private fun writeCookies(source: ProblemSource, cookies: String) { - PasswordSafe.instance.setPassword(credentialAttributes(source), cookies.ifBlank { null }) + cookieCache[source] = cookies + persistExecutor.execute { + PasswordSafe.instance.setPassword(credentialAttributes(source), cookies.ifBlank { null }) + } } private fun readCookies(source: ProblemSource): String = - PasswordSafe.instance.getPassword(credentialAttributes(source)) ?: "" + cookieCache.getOrPut(source) { + // 캐시 예열 전에 호출된 경우의 폴백 (드묾) + PasswordSafe.instance.getPassword(credentialAttributes(source)) ?: "" + } fun getCookies(source: ProblemSource): String = readCookies(source) diff --git a/src/main/kotlin/com/codingtestkit/service/CodeRunner.kt b/src/main/kotlin/com/codingtestkit/service/CodeRunner.kt index a639235..40d748d 100644 --- a/src/main/kotlin/com/codingtestkit/service/CodeRunner.kt +++ b/src/main/kotlin/com/codingtestkit/service/CodeRunner.kt @@ -35,6 +35,9 @@ object CodeRunner { Language.CPP -> runCpp(code, testCase.input, tempDir, timeoutSeconds) Language.KOTLIN -> runKotlin(code, testCase.input, tempDir, timeoutSeconds) Language.JAVASCRIPT -> runJavaScript(code, testCase.input, tempDir, timeoutSeconds) + Language.RUST -> runRust(code, testCase.input, tempDir, timeoutSeconds) + Language.GO -> runGo(code, testCase.input, tempDir, timeoutSeconds) + Language.RUBY -> runRuby(code, testCase.input, tempDir, timeoutSeconds) } } catch (e: Exception) { RunResult(output = "", error = e.message ?: "Unknown error", exitCode = -1) @@ -69,6 +72,9 @@ object CodeRunner { Language.CPP -> wrapCpp(code, inputValues, parameterNames) Language.KOTLIN -> wrapKotlin(code, inputValues, parameterNames) Language.JAVASCRIPT -> wrapJavaScript(code, inputValues, parameterNames) + Language.RUST -> wrapRust(code, inputValues, parameterNames) + Language.GO -> wrapGo(code, inputValues, parameterNames) + Language.RUBY -> wrapRuby(code, inputValues, parameterNames) } val tempDir = createTempDir() @@ -79,6 +85,9 @@ object CodeRunner { Language.CPP -> runCpp(wrappedCode, "", tempDir, timeoutSeconds) Language.KOTLIN -> runKotlin(wrappedCode, "", tempDir, timeoutSeconds) Language.JAVASCRIPT -> runJavaScript(wrappedCode, "", tempDir, timeoutSeconds) + Language.RUST -> runRust(wrappedCode, "", tempDir, timeoutSeconds) + Language.GO -> runGo(wrappedCode, "", tempDir, timeoutSeconds) + Language.RUBY -> runRuby(wrappedCode, "", tempDir, timeoutSeconds) } } catch (e: Exception) { RunResult(output = "", error = e.message ?: "Unknown error", exitCode = -1) @@ -94,6 +103,10 @@ object CodeRunner { Language.CPP -> code.contains("int main") Language.KOTLIN -> code.contains("fun main") Language.JAVASCRIPT -> code.contains("readline") || code.contains("process.stdin") + Language.RUST -> code.contains("fn main") + Language.GO -> code.contains("func main") + // Ruby는 main이 없으므로 stdin 사용 여부로 스크립트/함수 스타일을 구분 + Language.RUBY -> code.contains("gets") || code.contains("STDIN") } } @@ -447,6 +460,160 @@ else console.log(_result); """.trimIndent() } + /** + * Rust 배열 변환: [1,2,3] → vec![1,2,3], 문자열 → String::from("...") + */ + private fun toRustLiteral(value: String): String { + val v = value.trim() + if (v.startsWith("\"")) return "String::from($v)" + if (!v.startsWith("[")) return v + + if (v.startsWith("[[")) { + val inner = v.removePrefix("[").removeSuffix("]") + val arrays = mutableListOf() + var depth = 0 + var current = StringBuilder() + for (c in inner) { + if (c == '[') depth++ + if (c == ']') depth-- + current.append(c) + if (depth == 0 && current.isNotBlank()) { + val arr = current.toString().trim().removePrefix(",").trim() + if (arr.isNotBlank()) arrays.add(arr) + current = StringBuilder() + } + } + val converted = arrays.joinToString(", ") { toRustLiteral(it) } + return "vec![$converted]" + } + + val content = v.removePrefix("[").removeSuffix("]").trim() + if (content.isEmpty()) return "vec![]" + val first = content.split(",").firstOrNull()?.trim() ?: "" + return if (first.startsWith("\"")) { + val items = content.split(",").joinToString(", ") { "String::from(${it.trim()})" } + "vec![$items]" + } else { + "vec![$content]" + } + } + + /** + * Go 배열 변환: [1,2,3] → []int{1,2,3}, [[1,2],[3,4]] → [][]int{{1,2},{3,4}} + */ + private fun toGoLiteral(value: String): String { + val v = value.trim() + if (!v.startsWith("[")) return v + + if (v.startsWith("[[")) { + // 첫 내부 배열의 원소로 타입 감지: [[1,2],[3,4]] → [][]int{{1,2},{3,4}} + val firstInner = v.removePrefix("[").substringAfter("[").substringBefore("]") + val elemType = detectGoElementType(firstInner) + return "[][]$elemType" + v.replace('[', '{').replace(']', '}') + } + + val content = v.removePrefix("[").removeSuffix("]").trim() + if (content.isEmpty()) return "[]int{}" + val elemType = detectGoElementType(content) + return "[]$elemType{$content}" + } + + private fun detectGoElementType(content: String): String { + val first = content.split(",").firstOrNull()?.trim() ?: "" + return when { + first.startsWith("\"") -> "string" + first == "true" || first == "false" -> "bool" + first.contains(".") -> "float64" + first.toLongOrNull() != null && (first.toLong() > Int.MAX_VALUE || first.toLong() < Int.MIN_VALUE) -> "int64" + else -> "int" + } + } + + private fun wrapRust(code: String, inputValues: List, @Suppress("UNUSED_PARAMETER") paramNames: List): String { + val args = inputValues.joinToString(", ") { toRustLiteral(it) } + + // fn 메서드명 추출 (solution 우선, 없으면 마지막) + val rsFns = Regex("""fn\s+(\w+)\s*\(""").findAll(code) + .map { it.groupValues[1] } + .filter { it != "main" }.toList() + val methodName = rsFns.find { it == "solution" } ?: rsFns.lastOrNull() ?: "solution" + + // LeetCode 스타일(impl Solution)이면 struct 선언 보충 후 연관 함수로 호출 + val hasImpl = code.contains("impl Solution") + val structDecl = if (hasImpl && !code.contains("struct Solution")) "struct Solution;\n\n" else "" + val callExpr = if (hasImpl) "Solution::$methodName($args)" else "$methodName($args)" + + return """ +$structDecl$code + +fn main() { + let _result = $callExpr; + // {:?}는 문자열을 "따옴표 포함"으로, 벡터를 [a, b]로 출력 → 공백 제거로 [a,b] 형태 통일 + let _s = format!("{:?}", _result).replace(", ", ","); + println!("{}", _s); +} +""".trimIndent() + } + + private fun wrapGo(code: String, inputValues: List, @Suppress("UNUSED_PARAMETER") paramNames: List): String { + val args = inputValues.joinToString(", ") { toGoLiteral(it) } + + val goFuncs = Regex("""func\s+(\w+)\s*\(""").findAll(code) + .map { it.groupValues[1] } + .filter { it != "main" }.toList() + val methodName = goFuncs.find { it == "solution" } ?: goFuncs.lastOrNull() ?: "solution" + + // 사용자 코드에 package 선언이 있으면 제거 (래퍼가 package main을 제공) + val body = code.lines().filterNot { it.trim().startsWith("package ") }.joinToString("\n") + + // fmt 대신 json/os만 import: 사용자 코드의 import "fmt"와 충돌 방지 + return """ +package main + +import ( + "encoding/json" + "os" +) + +$body + +func main() { + _result := $methodName($args) + _b, _ := json.Marshal(_result) + os.Stdout.Write(append(_b, '\n')) +} +""".trimIndent() + } + + private fun wrapRuby(code: String, inputValues: List, @Suppress("UNUSED_PARAMETER") paramNames: List): String { + val args = inputValues.joinToString(", ") + + val rbMethods = Regex("""def\s+(\w+)""").findAll(code) + .map { it.groupValues[1] } + .filter { it != "initialize" }.toList() + val methodName = rbMethods.find { it == "solution" } ?: rbMethods.lastOrNull() ?: "solution" + + return """ +require 'json' + +$code + +# 사용자 puts를 stderr로 리다이렉트 +_orig_stdout = ${'$'}stdout +${'$'}stdout = ${'$'}stderr +_result = $methodName($args) +# stdout 복원 후 리턴값만 출력 +${'$'}stdout = _orig_stdout +if _result.is_a?(String) + puts "\"#{_result}\"" +elsif _result.is_a?(Array) + puts _result.to_json +else + puts _result +end +""".trimIndent() + } + // ─── 도구 경로 자동 감지 ─── private val javaHome: String by lazy { detectJavaHome() } @@ -456,6 +623,22 @@ else console.log(_result); private val gppPath: String by lazy { findExecutable("g++", "/usr/bin/g++", "/usr/local/bin/g++", "/opt/homebrew/bin/g++") } private val kotlincPath: String by lazy { detectKotlinc() } private val nodePath: String by lazy { detectNode() } + private val rustcPath: String by lazy { + findExecutable("rustc", + "${System.getProperty("user.home")}/.cargo/bin/rustc", + "/usr/local/bin/rustc", "/opt/homebrew/bin/rustc") + } + private val goPath: String by lazy { + findExecutable("go", + "/usr/local/go/bin/go", "/opt/homebrew/bin/go", "/usr/local/bin/go", + "${System.getProperty("user.home")}/go/bin/go") + } + private val rubyPath: String by lazy { + findExecutable("ruby", + "${System.getProperty("user.home")}/.rbenv/shims/ruby", + "/opt/homebrew/opt/ruby/bin/ruby", "/usr/local/opt/ruby/bin/ruby", + "/usr/bin/ruby") + } private fun detectJavaHome(): String { // 1. JAVA_HOME 환경변수 @@ -604,7 +787,10 @@ else console.log(_result); "python" to pythonPath, "node" to nodePath, "g++" to gppPath, - "kotlinc" to kotlincPath + "kotlinc" to kotlincPath, + "rustc" to rustcPath, + "go" to goPath, + "ruby" to rubyPath ) // ─── 언어별 컴파일 & 실행 ─── @@ -769,8 +955,10 @@ else console.log(_result); sourceFile.writeText(code) val jarFile = File(dir, "solution.jar") + // -J-Dfile.encoding: javac의 -encoding UTF-8과 동일한 목적 (이슈 #2). + // Windows에서 kotlinc가 구버전 JDK(<18)로 실행되면 MS949로 소스를 읽어 한글이 깨지는 것을 방지. val compile = executeProcess( - listOf(kotlincPath, sourceFile.absolutePath, "-include-runtime", "-d", jarFile.absolutePath), + listOf(kotlincPath, "-J-Dfile.encoding=UTF-8", sourceFile.absolutePath, "-include-runtime", "-d", jarFile.absolutePath), dir, "", timeout * 2 ) if (compile.exitCode != 0) { @@ -792,6 +980,63 @@ else console.log(_result); return executeProcess(listOf(nodePath, sourceFile.absolutePath), dir, input, timeout) } + private fun runRust(code: String, input: String, dir: File, timeout: Long): RunResult { + if (rustcPath.isBlank()) { + return RunResult(output = "", error = I18n.t( + "rustc를 찾을 수 없습니다.\nhttps://rustup.rs 에서 Rust를 설치하세요.", + "rustc not found.\nPlease install Rust via https://rustup.rs" + ), exitCode = -1) + } + val sourceFile = File(dir, "solution.rs") + // rustc/go build는 g++(MinGW)와 달리 Windows에서 .exe를 자동으로 붙이지 않음 + val outputFile = File(dir, if (isWindows) "solution.exe" else "solution") + sourceFile.writeText(code, StandardCharsets.UTF_8) + + val compile = executeProcess( + listOf(rustcPath, "-O", "--edition", "2021", "-o", outputFile.absolutePath, sourceFile.absolutePath), + dir, "", timeout * 2 + ) + if (compile.exitCode != 0) { + return RunResult(output = "", error = I18n.t("컴파일 에러", "Compile error") + ":\n${compile.error}", exitCode = compile.exitCode) + } + + return executeProcess(listOf(outputFile.absolutePath), dir, input, timeout) + } + + private fun runGo(code: String, input: String, dir: File, timeout: Long): RunResult { + if (goPath.isBlank()) { + return RunResult(output = "", error = I18n.t( + "Go를 찾을 수 없습니다.\nhttps://go.dev/dl 에서 Go를 설치하세요.", + "Go not found.\nPlease install Go via https://go.dev/dl" + ), exitCode = -1) + } + val sourceFile = File(dir, "solution.go") + val outputFile = File(dir, if (isWindows) "solution.exe" else "solution") + sourceFile.writeText(code, StandardCharsets.UTF_8) + + val compile = executeProcess( + listOf(goPath, "build", "-o", outputFile.absolutePath, sourceFile.absolutePath), + dir, "", timeout * 2 + ) + if (compile.exitCode != 0) { + return RunResult(output = "", error = I18n.t("컴파일 에러", "Compile error") + ":\n${compile.error}", exitCode = compile.exitCode) + } + + return executeProcess(listOf(outputFile.absolutePath), dir, input, timeout) + } + + private fun runRuby(code: String, input: String, dir: File, timeout: Long): RunResult { + if (rubyPath.isBlank()) { + return RunResult(output = "", error = I18n.t( + "Ruby를 찾을 수 없습니다.\nbrew install ruby 또는 rbenv로 설치하세요.", + "Ruby not found.\nPlease install via: brew install ruby" + ), exitCode = -1) + } + val sourceFile = File(dir, "solution.rb") + sourceFile.writeText(code, StandardCharsets.UTF_8) + return executeProcess(listOf(rubyPath, sourceFile.absolutePath), dir, input, timeout) + } + private fun executeProcess( command: List, dir: File, @@ -803,8 +1048,12 @@ else console.log(_result); .redirectErrorStream(false) .start() + // 입력이 없어도 stdin을 닫아 EOF를 전달해야 함. + // 닫지 않으면 stdin을 읽는 코드가 타임아웃까지 블로킹되어 허위 시간 초과가 발생하고 파이프 FD가 누수됨. if (input.isNotBlank()) { process.outputStream.bufferedWriter(Charsets.UTF_8).use { it.write(input) } + } else { + process.outputStream.close() } // 메모리 폴링 스레드 시작 @@ -851,8 +1100,8 @@ else console.log(_result); return RunResult(output = "", error = I18n.t("시간 초과 (${timeout}초)", "Time Limit Exceeded (${timeout}s)"), exitCode = -1, timedOut = true, executionTimeMs = elapsedMs, peakMemoryKB = peakMemory.get()) } - val output = process.inputStream.bufferedReader(Charsets.UTF_8).readText().trimEnd() - val error = process.errorStream.bufferedReader(Charsets.UTF_8).readText().trimEnd() + val output = process.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }.trimEnd() + val error = process.errorStream.bufferedReader(Charsets.UTF_8).use { it.readText() }.trimEnd() return RunResult(output = output, error = error, exitCode = process.exitValue(), executionTimeMs = elapsedMs, peakMemoryKB = peakMemory.get()) } diff --git a/src/main/kotlin/com/codingtestkit/service/CodeforcesCrawler.kt b/src/main/kotlin/com/codingtestkit/service/CodeforcesCrawler.kt index 63703ce..efc1c6f 100644 --- a/src/main/kotlin/com/codingtestkit/service/CodeforcesCrawler.kt +++ b/src/main/kotlin/com/codingtestkit/service/CodeforcesCrawler.kt @@ -21,14 +21,29 @@ object CodeforcesCrawler { return match.groupValues[1] to match.groupValues[2].uppercase() } - fun fetchProblem(problemId: String): Problem { + fun fetchProblem(problemId: String, cookies: String = ""): Problem { val (contestId, letter) = parseProblemId(problemId) - val doc = Jsoup.connect("${BASE_URL}$contestId/$letter?locale=en") - .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + // 로그인 쿠키(cf_clearance 포함)를 함께 보내면 Cloudflare 챌린지를 통과할 수 있음. + // cf_clearance는 User-Agent에 바인딩되므로 JCEF(Chrome)와 유사한 UA를 사용. + val connection = Jsoup.connect("${BASE_URL}$contestId/$letter?locale=en") + .userAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") .timeout(30000) - .get() + if (cookies.isNotBlank()) connection.header("Cookie", cookies) + val doc = connection.get() + return parseProblemDoc(doc, contestId, letter) + } + + /** + * JCEF 폴백 경로: 브라우저가 렌더링한 HTML을 동일한 파서로 처리. + * Cloudflare가 Jsoup의 TLS 지문을 차단할 때 사용된다. + */ + fun parseProblemHtml(html: String, problemId: String): Problem { + val (contestId, letter) = parseProblemId(problemId) + return parseProblemDoc(Jsoup.parse(html, "https://codeforces.com"), contestId, letter) + } + private fun parseProblemDoc(doc: org.jsoup.nodes.Document, contestId: String, letter: String): Problem { // 이미지: 상대 경로 → 절대 경로, 고정 크기 제거 doc.select("img[src]").forEach { img -> val src = img.attr("src") diff --git a/src/main/kotlin/com/codingtestkit/service/CodeforcesJcefFetcher.kt b/src/main/kotlin/com/codingtestkit/service/CodeforcesJcefFetcher.kt new file mode 100644 index 0000000..9aa72e0 --- /dev/null +++ b/src/main/kotlin/com/codingtestkit/service/CodeforcesJcefFetcher.kt @@ -0,0 +1,186 @@ +package com.codingtestkit.service + +import com.codingtestkit.model.Problem +import com.intellij.openapi.diagnostic.Logger +import com.intellij.ui.jcef.JBCefApp +import com.intellij.ui.jcef.JBCefBrowser +import com.intellij.ui.jcef.JBCefBrowserBase +import com.intellij.ui.jcef.JBCefJSQuery +import org.cef.browser.CefBrowser +import org.cef.browser.CefFrame +import org.cef.handler.CefLoadHandlerAdapter +import java.awt.Dimension +import java.awt.Window +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicBoolean +import javax.swing.JFrame +import javax.swing.SwingUtilities +import javax.swing.Timer + +/** + * JCEF 오프스크린 브라우저를 사용한 Codeforces 문제 가져오기 폴백. + * + * Codeforces는 Cloudflare가 비브라우저 TLS 지문(Jsoup/Java)을 403으로 차단할 수 있다. + * 실제 Chromium(JCEF)으로 페이지를 열면 챌린지를 통과하므로, 렌더링된 HTML을 + * 추출해 CodeforcesCrawler의 기존 파서에 넘긴다. (SweaJcefFetcher와 동일 패턴) + */ +object CodeforcesJcefFetcher { + + private val LOG = Logger.getInstance(CodeforcesJcefFetcher::class.java) + + var lastError: String = "" + private set + + // Cloudflare 챌린지 통과 시간(수 초~수십 초)을 포함한 타임아웃 + private const val FETCH_TIMEOUT_MS = 60_000L + + fun isAvailable(): Boolean = try { + JBCefApp.isSupported() + } catch (_: Exception) { + false + } + + /** + * 백그라운드 스레드에서 호출할 것 (EDT에서 호출하면 deadlock). + */ + fun fetchProblem(problemId: String): Problem? { + if (!isAvailable()) { + lastError = "JCEF not available" + return null + } + + val future = CompletableFuture() + + SwingUtilities.invokeLater { + try { + FetchSession(problemId, future).start() + } catch (e: Exception) { + LOG.warn("[CodingTestKit] CodeforcesJcefFetcher init failed", e) + lastError = "Init: ${e.message}" + if (!future.isDone) future.complete(null) + } + } + + return try { + future.get(FETCH_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } catch (_: TimeoutException) { + lastError = "Timeout (${FETCH_TIMEOUT_MS / 1000}s)" + LOG.info("[CodingTestKit] Codeforces JCEF fetch timeout: $problemId") + null + } catch (e: Exception) { + lastError = "Error: ${e.message}" + LOG.warn("[CodingTestKit] Codeforces JCEF fetch error", e) + null + } + } + + private class FetchSession( + private val problemId: String, + private val future: CompletableFuture + ) { + private val done = AtomicBoolean(false) + private var frame: JFrame? = null + private var browser: JBCefBrowser? = null + private var timeoutTimer: Timer? = null + + fun start() { + val (contestId, letter) = CodeforcesCrawler.parseProblemId(problemId) + val url = "https://codeforces.com/problemset/problem/$contestId/$letter?locale=en" + + frame = JFrame().apply { + type = Window.Type.UTILITY + isUndecorated = true + size = Dimension(1024, 768) + setLocation(-3000, -3000) + } + + browser = JBCefBrowser(url) + frame!!.contentPane.add(browser!!.component) + frame!!.isVisible = true + + LOG.info("[CodingTestKit] Codeforces JCEF fetch started: $problemId") + + val jsQuery = JBCefJSQuery.create(browser as JBCefBrowserBase) + jsQuery.addHandler { html -> + handleHtml(html) + JBCefJSQuery.Response("") + } + + timeoutTimer = Timer((FETCH_TIMEOUT_MS - 5000).toInt()) { + complete(null, "JCEF internal timeout") + }.apply { isRepeats = false; start() } + + browser!!.jbCefClient.addLoadHandler(object : CefLoadHandlerAdapter() { + override fun onLoadEnd(cefBrowser: CefBrowser?, cefFrame: CefFrame?, httpStatusCode: Int) { + if (cefFrame?.isMain != true || done.get()) return + LOG.info("[CodingTestKit] Codeforces JCEF loaded: ${cefBrowser?.url} (status=$httpStatusCode)") + // Cloudflare 챌린지 페이지일 수 있으므로 .problem-statement가 + // 나타날 때까지 폴링. 챌린지 통과 후 리다이렉트되면 onLoadEnd가 + // 다시 불리고 새 문서에 폴러가 재주입된다. + startPolling(cefBrowser, jsQuery) + } + }, browser!!.cefBrowser) + } + + private fun startPolling(cefBrowser: CefBrowser?, jsQuery: JBCefJSQuery) { + if (cefBrowser == null || done.get()) return + val queryJs = jsQuery.inject("html") + val js = """ + (function() { + if (window.__ctkCfPoller) return; + var attempts = 0; + window.__ctkCfPoller = setInterval(function() { + attempts++; + if (document.querySelector('.problem-statement')) { + clearInterval(window.__ctkCfPoller); + var html = document.documentElement.outerHTML; + $queryJs + } else if (attempts >= 90) { + clearInterval(window.__ctkCfPoller); + var html = ''; + $queryJs + } + }, 500); + })(); + """.trimIndent() + cefBrowser.executeJavaScript(js, cefBrowser.url, 0) + } + + private fun handleHtml(html: String) { + if (html.isBlank() || !html.contains("problem-statement")) { + complete(null, "Cloudflare challenge not passed or unexpected page") + return + } + try { + val problem = CodeforcesCrawler.parseProblemHtml(html, problemId) + if (problem.title.isNotBlank() || problem.description.length > 50) { + complete(problem) + } else { + complete(null, "Parsed content empty") + } + } catch (e: Exception) { + complete(null, "Parse: ${e.message}") + } + } + + private fun complete(problem: Problem?, error: String? = null) { + if (!done.compareAndSet(false, true)) return + if (error != null) { + lastError = error + LOG.info("[CodingTestKit] Codeforces JCEF fetch error: $error") + } else { + LOG.info("[CodingTestKit] Codeforces JCEF fetch OK: ${problem?.title}") + } + timeoutTimer?.stop() + + SwingUtilities.invokeLater { + try { browser?.dispose() } catch (_: Exception) {} + try { frame?.dispose() } catch (_: Exception) {} + } + + if (!future.isDone) future.complete(problem) + } + } +} diff --git a/src/main/kotlin/com/codingtestkit/service/DuplicateClassHighlightFilter.kt b/src/main/kotlin/com/codingtestkit/service/DuplicateClassHighlightFilter.kt index e9a3470..3170892 100644 --- a/src/main/kotlin/com/codingtestkit/service/DuplicateClassHighlightFilter.kt +++ b/src/main/kotlin/com/codingtestkit/service/DuplicateClassHighlightFilter.kt @@ -17,14 +17,12 @@ class DuplicateClassHighlightFilter : HighlightInfoFilter { if (file == null) return true if (info.severity != HighlightSeverity.ERROR) return true val desc = info.description ?: return true - val filePath = file.virtualFile?.path ?: return true + if (file.virtualFile == null) return true - // src 파일에서 problems/ 내 파일을 가리키는 중복 클래스 오류 숨김 + // 중복 클래스 오류 메시지가 problems/ 내 파일을 가리키면 숨김 // 예: "파일 '/path/problems/.../Main.java'에서 중복된 클래스가 발견되었습니다" - if (desc.contains("/problems/")) return false - - // problems 파일에서 외부 파일을 가리키는 중복 클래스 오류 숨김 - if (filePath.contains("/problems/") && desc.contains("\\problems\\")) return false + // Windows는 메시지의 경로가 \ 구분자로 나오므로 통일 후 비교 + if (desc.replace('\\', '/').contains("/problems/")) return false return true } diff --git a/src/main/kotlin/com/codingtestkit/service/GitHubService.kt b/src/main/kotlin/com/codingtestkit/service/GitHubService.kt index 68f7dc8..ad24e72 100644 --- a/src/main/kotlin/com/codingtestkit/service/GitHubService.kt +++ b/src/main/kotlin/com/codingtestkit/service/GitHubService.kt @@ -45,12 +45,33 @@ class GitHubService : PersistentStateComponent { private val tokenCredentialAttributes: CredentialAttributes = CredentialAttributes(generateServiceName("CodingTestKit", "github.token")) + /** + * PasswordSafe(OS 키체인) 접근은 느려서 EDT에서 호출하면 SlowOperations 위반. + * AuthService와 동일하게 메모리 캐시로 읽고, 쓰기는 백그라운드로 영속화한다. + */ + @Volatile + private var cachedToken: String? = null + private val persistExecutor = java.util.concurrent.Executors.newSingleThreadExecutor { r -> + Thread(r, "CodingTestKit-GitHubPersist").apply { isDaemon = true } + } + + init { + persistExecutor.execute { + if (cachedToken == null) { + cachedToken = PasswordSafe.instance.getPassword(tokenCredentialAttributes) ?: "" + } + } + } + private fun writeToken(value: String) { - PasswordSafe.instance.setPassword(tokenCredentialAttributes, value.ifBlank { null }) + cachedToken = value + persistExecutor.execute { + PasswordSafe.instance.setPassword(tokenCredentialAttributes, value.ifBlank { null }) + } } private fun readToken(): String = - PasswordSafe.instance.getPassword(tokenCredentialAttributes) ?: "" + cachedToken ?: (PasswordSafe.instance.getPassword(tokenCredentialAttributes) ?: "").also { cachedToken = it } val token: String get() = readToken() val repoFullName: String get() = state.repoFullName diff --git a/src/main/kotlin/com/codingtestkit/service/LeetCodeApi.kt b/src/main/kotlin/com/codingtestkit/service/LeetCodeApi.kt index 6d43985..4acd4f0 100644 --- a/src/main/kotlin/com/codingtestkit/service/LeetCodeApi.kt +++ b/src/main/kotlin/com/codingtestkit/service/LeetCodeApi.kt @@ -309,6 +309,9 @@ object LeetCodeApi { "c++", "cpp" -> "cpp" "kotlin", "kt" -> "kotlin" "javascript", "js" -> "javascript" + "rust", "rs" -> "rust" + "go", "golang" -> "golang" + "ruby", "rb" -> "ruby" else -> "java" } diff --git a/src/main/kotlin/com/codingtestkit/service/ProblemFileManager.kt b/src/main/kotlin/com/codingtestkit/service/ProblemFileManager.kt index 1101fcf..4dedde3 100644 --- a/src/main/kotlin/com/codingtestkit/service/ProblemFileManager.kt +++ b/src/main/kotlin/com/codingtestkit/service/ProblemFileManager.kt @@ -291,7 +291,7 @@ object ProblemFileManager { } if (matchingDir != null) { val codeFile = matchingDir.listFiles()?.firstOrNull { - it.extension in listOf("java", "py", "cpp", "kt", "js") + it.extension in Language.entries.map { lang -> lang.extension } } if (codeFile != null) { return LocalFileSystem.getInstance().refreshAndFindFileByIoFile(codeFile) diff --git a/src/main/kotlin/com/codingtestkit/service/SubmitService.kt b/src/main/kotlin/com/codingtestkit/service/SubmitService.kt index d930bbe..058eb40 100644 --- a/src/main/kotlin/com/codingtestkit/service/SubmitService.kt +++ b/src/main/kotlin/com/codingtestkit/service/SubmitService.kt @@ -39,7 +39,10 @@ object SubmitService { Language.PYTHON to "python3", Language.CPP to "cpp", Language.KOTLIN to "kotlin", - Language.JAVASCRIPT to "javascript" + Language.JAVASCRIPT to "javascript", + // Rust는 프로그래머스 미지원 → 맵에서 제외 (미지원 안내 처리) + Language.GO to "go", + Language.RUBY to "ruby" ) private fun submitProgrammers( diff --git a/src/main/kotlin/com/codingtestkit/ui/CodeSubmitDialog.kt b/src/main/kotlin/com/codingtestkit/ui/CodeSubmitDialog.kt index ad239f5..03044d5 100644 --- a/src/main/kotlin/com/codingtestkit/ui/CodeSubmitDialog.kt +++ b/src/main/kotlin/com/codingtestkit/ui/CodeSubmitDialog.kt @@ -163,7 +163,9 @@ class CodeSubmitDialog( Language.PYTHON to "python3", Language.CPP to "cpp", Language.KOTLIN to "kotlin", - Language.JAVASCRIPT to "javascript" + Language.JAVASCRIPT to "javascript", + Language.GO to "go", + Language.RUBY to "ruby" ) private fun handleProgrammersLoad(cefBrowser: CefBrowser?, url: String) { @@ -190,12 +192,16 @@ class CodeSubmitDialog( Language.CPP -> "C++" Language.KOTLIN -> "Kotlin" Language.JAVASCRIPT -> "JavaScript" + Language.RUST -> "Rust" // 프로그래머스 미지원 — 드롭다운에 없으므로 언어 전환 없이 코드만 입력됨 + Language.GO -> "Go" + Language.RUBY -> "Ruby" } private fun injectProgrammersCode(cefBrowser: CefBrowser?) { if (cefBrowser == null) return val escaped = escapeForJs(code) - val langValue = programmersLangMap[language] ?: "java" + // 맵에 없는 언어(프로그래머스 미지원)는 드롭다운과 매칭되지 않는 값으로 두어 언어 전환을 건너뜀 + val langValue = programmersLangMap[language] ?: language.displayName.lowercase() val langDisplay = getProgrammersLangDisplayName() val js = """ @@ -420,6 +426,9 @@ class CodeSubmitDialog( Language.CPP -> "C++" Language.KOTLIN -> "Kotlin" Language.JAVASCRIPT -> "JavaScript" + Language.RUST -> "Rust" + Language.GO -> "Go" + Language.RUBY -> "Ruby" } private fun injectLeetCodeCode(cefBrowser: CefBrowser?) { @@ -514,6 +523,9 @@ class CodeSubmitDialog( Language.CPP -> "GNU C++20" Language.KOTLIN -> "Kotlin" Language.JAVASCRIPT -> "Node.js" + Language.RUST -> "Rust" + Language.GO -> "Go" + Language.RUBY -> "Ruby" } private fun handleCodeforcesLoad(cefBrowser: CefBrowser?, url: String) { diff --git a/src/main/kotlin/com/codingtestkit/ui/MainPanel.kt b/src/main/kotlin/com/codingtestkit/ui/MainPanel.kt index 7719dbf..304236a 100644 --- a/src/main/kotlin/com/codingtestkit/ui/MainPanel.kt +++ b/src/main/kotlin/com/codingtestkit/ui/MainPanel.kt @@ -40,6 +40,11 @@ class MainPanel(private val project: Project) : JPanel(BorderLayout()), Disposab add(tabbedPane, BorderLayout.CENTER) + // Problems ↔ Tests 언어 선택 양방향 동기화 + // (setLanguage가 같은 값이면 무시하므로 콜백이 무한 순환하지 않음) + problemPanel.onLanguageChanged = { testPanel.setLanguage(it) } + testPanel.onLanguageChanged = { problemPanel.setLanguage(it) } + // 문제를 가져오면 테스트 패널에 테스트 케이스 전달 problemPanel.onProblemFetched = { problem -> testPanel.setProblemSource(problem.source) diff --git a/src/main/kotlin/com/codingtestkit/ui/ProblemPanel.kt b/src/main/kotlin/com/codingtestkit/ui/ProblemPanel.kt index 0d65473..2b0c47a 100644 --- a/src/main/kotlin/com/codingtestkit/ui/ProblemPanel.kt +++ b/src/main/kotlin/com/codingtestkit/ui/ProblemPanel.kt @@ -50,6 +50,11 @@ class ProblemPanel(private val project: Project) : JPanel(BorderLayout()) { preferredSize = Dimension(JBUI.scale(90), preferredSize.height) renderer = createComboRenderer() } + private val submitSupportLabel = JLabel().apply { + font = font.deriveFont(JBUI.scaleFontSize(11f).toFloat()) + foreground = JBColor(Color(0xB0, 0x6A, 0x00), Color(0xE0, 0xA0, 0x50)) + isVisible = false + } private val problemIdField = JTextField().apply { toolTipText = I18n.t("문제 번호 또는 URL을 입력하세요", "Enter problem number or URL") } @@ -95,6 +100,9 @@ class ProblemPanel(private val project: Project) : JPanel(BorderLayout()) { private var currentProblemHtml = "" var onProblemFetched: ((Problem) -> Unit)? = null + + /** 언어 선택 변경 알림 (Tests 탭과 양방향 동기화용) */ + var onLanguageChanged: ((Language) -> Unit)? = null private var currentProblem: Problem? = null private var currentProblemFolder: java.io.File? = null private var isTranslated = false @@ -119,6 +127,7 @@ class ProblemPanel(private val project: Project) : JPanel(BorderLayout()) { row1.add(languageCombo) row1.add(loginButton) row1.add(githubLoginButton) + row1.add(submitSupportLabel) topPanel.add(row1) // Row 2: 문제번호 입력 + 가져오기 (WrapLayout으로 반응형) @@ -236,12 +245,19 @@ class ProblemPanel(private val project: Project) : JPanel(BorderLayout()) { submitButton.isEnabled = false githubPushButton.isEnabled = false - updateLoginButton() - updateGitHubButton() - // 백그라운드에서 저장된 쿠키 유효성 검증 + // 첫 서비스 접근은 PasswordSafe(키체인)를 읽으므로 EDT 금지 — + // 백그라운드에서 캐시를 예열한 뒤 버튼 상태를 갱신하고, 이어서 쿠키 유효성 검증 ApplicationManager.getApplication().executeOnPooledThread { val auth = AuthService.getInstance() + val github = GitHubService.getInstance() + for (source in ProblemSource.entries) auth.getCookies(source) + github.token + SwingUtilities.invokeLater { + updateLoginButton() + updateGitHubButton() + } + for (source in ProblemSource.entries) { if (auth.isLoggedIn(source)) { if (!auth.validateSession(source)) { @@ -255,6 +271,11 @@ class ProblemPanel(private val project: Project) : JPanel(BorderLayout()) { sourceCombo.addActionListener { updateLoginButton() updatePlaceholder() + updateSubmitSupportHint() + } + languageCombo.addActionListener { + updateSubmitSupportHint() + onLanguageChanged?.invoke(getSelectedLanguage()) } fetchButton.addActionListener { fetchProblem() } randomButton.addActionListener { openRandomDialog() } @@ -267,6 +288,7 @@ class ProblemPanel(private val project: Project) : JPanel(BorderLayout()) { githubLoginButton.addActionListener { handleGitHubLogin() } problemIdField.addActionListener { fetchProblem() } updatePlaceholder() + updateSubmitSupportHint() // 키보드 단축키 액션 등록 CodingTestKitActionService.getInstance(project).apply { @@ -276,6 +298,23 @@ class ProblemPanel(private val project: Project) : JPanel(BorderLayout()) { } } + /** + * 선택한 (플랫폼, 언어) 조합이 제출 미지원이면 콤보 옆에 즉시 경고 표시. + * 문제를 다 푼 뒤 제출 시점에야 미지원을 알게 되는 상황을 방지한다. + */ + private fun updateSubmitSupportHint() { + val source = getSelectedSource() + val language = getSelectedLanguage() + val submittable = language.isSubmittable(source) + submitSupportLabel.isVisible = !submittable + if (!submittable) { + submitSupportLabel.text = I18n.t( + "⚠ ${language.displayName} — ${source.localizedName()} 제출 미지원 (로컬 테스트만 가능)", + "⚠ ${language.displayName} — cannot submit to ${source.localizedName()} (local testing only)" + ) + } + } + private fun updatePlaceholder() { val source = getSelectedSource() val (placeholder, tooltip) = when (source) { @@ -334,6 +373,14 @@ class ProblemPanel(private val project: Project) : JPanel(BorderLayout()) { private fun getSelectedSource(): ProblemSource = ProblemSource.entries[sourceCombo.selectedIndex] private fun getSelectedLanguage(): Language = Language.entries[languageCombo.selectedIndex] + /** 외부(Tests 탭)에서 언어 동기화. 같은 값이면 무시해 콜백 루프를 끊는다. */ + fun setLanguage(language: Language) { + val idx = Language.entries.indexOf(language) + if (idx >= 0 && languageCombo.selectedIndex != idx) { + languageCombo.selectedIndex = idx + } + } + private fun updateLoginButton() { val source = getSelectedSource() val auth = AuthService.getInstance() @@ -659,7 +706,15 @@ class ProblemPanel(private val project: Project) : JPanel(BorderLayout()) { val problem = when (source) { ProblemSource.PROGRAMMERS -> ProgrammersCrawler.fetchProblem(id, cookies) ProblemSource.LEETCODE -> LeetCodeApi.fetchProblem(id, language.extension, cookies) - ProblemSource.CODEFORCES -> CodeforcesCrawler.fetchProblem(id) + ProblemSource.CODEFORCES -> try { + CodeforcesCrawler.fetchProblem(id, cookies) + } catch (e: Exception) { + // Cloudflare가 Jsoup을 차단(403)하면 실브라우저(JCEF)로 폴백 + if (CodeforcesJcefFetcher.isAvailable()) { + CodeforcesJcefFetcher.fetchProblem(id) + ?: throw IllegalStateException("${e.message}\nJCEF fallback: ${CodeforcesJcefFetcher.lastError}") + } else throw e + } ProblemSource.SWEA -> throw IllegalStateException("unreachable") } diff --git a/src/main/kotlin/com/codingtestkit/ui/TestPanel.kt b/src/main/kotlin/com/codingtestkit/ui/TestPanel.kt index 13cb4c5..12e35bc 100644 --- a/src/main/kotlin/com/codingtestkit/ui/TestPanel.kt +++ b/src/main/kotlin/com/codingtestkit/ui/TestPanel.kt @@ -48,6 +48,17 @@ class TestPanel(private val project: Project) : JPanel(BorderLayout()) { private var problemSource = ProblemSource.PROGRAMMERS private var parameterNames = listOf() + /** 언어 선택 변경 알림 (Problems 탭과 양방향 동기화용) */ + var onLanguageChanged: ((Language) -> Unit)? = null + + /** 외부(Problems 탭)에서 언어 동기화. 같은 값이면 무시해 콜백 루프를 끊는다. */ + fun setLanguage(language: Language) { + val idx = Language.entries.indexOf(language) + if (idx >= 0 && languageCombo.selectedIndex != idx) { + languageCombo.selectedIndex = idx + } + } + init { border = JBUI.Borders.empty() @@ -65,6 +76,7 @@ class TestPanel(private val project: Project) : JPanel(BorderLayout()) { foreground = JBColor.GRAY }) buttonRow.add(languageCombo) + languageCombo.addActionListener { onLanguageChanged?.invoke(getSelectedLanguage()) } buttonRow.add(runButton) buttonRow.add(addButton) topPanel.add(buttonRow) diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 38a0a54..f437362 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -1,7 +1,7 @@ com.codingtestkit CodingTestKit - 1.4.4 + 1.5.0 Su Beom

Supported Languages

-

Java, Python, C++, Kotlin, JavaScript

+

Java, Python, C++, Kotlin, JavaScript, Rust, Go, Ruby

+

Local test execution works for all languages on every platform. Submission availability depends on each platform — unsupported combinations show a warning at selection time.

Getting Started

    @@ -96,7 +97,7 @@
    • 문제 가져오기 – 번호 입력만으로 문제 설명, 테스트 케이스 자동 추출
    • 문제 보기 & 번역 – IDE 내에서 문제 확인 + KaTeX 수식 렌더링 + 한/영 원클릭 번역
    • -
    • 로컬 테스트 – Java, Python, C++, Kotlin, JavaScript 코드를 IDE에서 바로 실행 + 실행 시간 & 메모리
    • +
    • 로컬 테스트 – Java, Python, C++, Kotlin, JavaScript, Rust, Go, Ruby 코드를 IDE에서 바로 실행 + 실행 시간 & 메모리
    • 로그인 & 제출 – 내장 브라우저로 직접 제출 + 언어/코드/문제 자동 선택
    • 문제 검색 – LeetCode, Codeforces, 프로그래머스에서 키워드·태그·난이도로 검색
    • 랜덤 뽑기 – 티어/클래스/레이팅 범위, 태그 칩, 풀이 필터, 정답자 수 필터, 기출문제 모음 필터
    • @@ -109,7 +110,8 @@

    지원 언어

    -

    Java, Python, C++, Kotlin, JavaScript

    +

    Java, Python, C++, Kotlin, JavaScript, Rust, Go, Ruby

    +

    로컬 테스트 실행은 모든 플랫폼에서 전 언어 지원. 제출은 플랫폼별로 다르며, 미지원 조합 선택 시 즉시 경고가 표시됩니다.

    시작하기

      @@ -155,6 +157,33 @@ ]]> v1.5.0 +

      Rust, Go, and Ruby language support (requested in #7) + Cloudflare-resistant Codeforces fetching.

      + + English +
        +
      • [New] Rust / Go / Ruby support – Local test execution (compile/run + automatic toolchain detection incl. ~/.cargo/bin, /usr/local/go/bin, rbenv), Programmers-style solution wrappers with argument conversion, per-platform default templates, and submission for LeetCode / Codeforces (Programmers supports Go/Ruby; Rust is not supported by that platform).
      • +
      • [New] Submit-support early warning – Selecting a (platform, language) combination that can't be submitted (e.g. Rust × Programmers, Kotlin × SWEA) shows an immediate hint next to the selectors instead of failing at submission time. Local testing works for every combination.
      • +
      • [New] Codeforces Cloudflare fallback – When Cloudflare blocks HTTP crawling (403), problem fetching automatically falls back to an offscreen embedded browser (JCEF) that passes the challenge. Fetch may take a few extra seconds when the fallback engages.
      • +
      • [New] Language sync between tabs – The language selector in the Problems tab and Tests tab now stay in sync bidirectionally.
      • +
      • [Fix] False "Time Limit Exceeded" on empty input – stdin is now always closed so programs reading input see EOF instead of blocking until timeout; also fixes a pipe file-descriptor leak on every compile.
      • +
      • [Fix] Kotlin compilation encoding on Windowskotlinc now runs with -J-Dfile.encoding=UTF-8 (same fix as javac in v1.4.3), preventing garbled Korean source with older JDKs.
      • +
      • [Fix] Duplicate-class error suppression on Windows – The highlight filter now handles Windows path separators.
      • +
      • [Fix] UI freezes from keychain access – Login cookies and the GitHub token are no longer read from the OS keychain on the UI thread (SlowOperations violations); they are served from in-memory caches with background persistence.
      • +
      + + 한국어 +
        +
      • [신규] Rust / Go / Ruby 지원 – 로컬 테스트 실행(컴파일/실행 + ~/.cargo/bin, /usr/local/go/bin, rbenv 등 툴체인 자동 탐지), 프로그래머스식 solution 래퍼(인자 자동 변환), 플랫폼별 기본 템플릿, LeetCode/Codeforces 제출 지원 (프로그래머스는 Go/Ruby 지원, Rust는 플랫폼 미지원). 이슈 #7 요청 반영.
      • +
      • [신규] 제출 미지원 조합 즉시 경고 – 제출이 불가능한 (플랫폼, 언어) 조합(예: Rust × 프로그래머스, Kotlin × SWEA)을 선택하면 제출 시점이 아니라 선택 즉시 콤보 옆에 안내가 표시됩니다. 로컬 테스트는 모든 조합에서 가능.
      • +
      • [신규] Codeforces Cloudflare 폴백 – HTTP 크롤링이 Cloudflare에 차단(403)되면 오프스크린 내장 브라우저(JCEF)가 챌린지를 통과해 문제를 가져옵니다. 폴백 동작 시 몇 초 더 소요될 수 있습니다.
      • +
      • [신규] 탭 간 언어 동기화 – 문제 탭과 테스트 탭의 언어 선택이 양방향으로 자동 동기화됩니다.
      • +
      • [수정] 빈 입력에서 허위 "시간 초과" – stdin을 항상 닫아 입력을 읽는 프로그램이 타임아웃까지 블로킹되지 않고 EOF를 받도록 수정. 컴파일마다 발생하던 파이프 FD 누수도 함께 해결.
      • +
      • [수정] Windows Kotlin 컴파일 인코딩kotlinc-J-Dfile.encoding=UTF-8 적용 (v1.4.3의 javac 수정과 동일 목적). 구버전 JDK에서 한글 소스가 깨지는 것을 방지.
      • +
      • [수정] Windows 중복 클래스 오류 억제 – 하이라이트 필터가 Windows 경로 구분자(\)를 처리하도록 수정.
      • +
      • [수정] 키체인 접근으로 인한 UI 멈칫거림 – 로그인 쿠키와 GitHub 토큰을 UI 스레드에서 OS 키체인으로 읽지 않도록(SlowOperations 위반) 메모리 캐시 + 백그라운드 영속화로 변경.
      • +
      +

      v1.4.4

      IntelliJ 2026.2 compatibility. The problem viewer now works across IDE versions with different JCEF APIs.

      diff --git a/src/test/kotlin/com/codingtestkit/model/ModelsTest.kt b/src/test/kotlin/com/codingtestkit/model/ModelsTest.kt index cec6da7..a1411be 100644 --- a/src/test/kotlin/com/codingtestkit/model/ModelsTest.kt +++ b/src/test/kotlin/com/codingtestkit/model/ModelsTest.kt @@ -47,6 +47,41 @@ class ModelsTest { assertEquals("py", Language.PYTHON.extension) assertEquals("cpp", Language.CPP.extension) assertEquals("kt", Language.KOTLIN.extension) + assertEquals("js", Language.JAVASCRIPT.extension) + assertEquals("rs", Language.RUST.extension) + assertEquals("go", Language.GO.extension) + assertEquals("rb", Language.RUBY.extension) + } + + @Test + fun `Rust Go Ruby defaultCode for Programmers has solution function`() { + assertTrue(Language.RUST.defaultCode(ProblemSource.PROGRAMMERS).contains("fn solution")) + assertTrue(Language.GO.defaultCode(ProblemSource.PROGRAMMERS).contains("func solution")) + assertTrue(Language.RUBY.defaultCode(ProblemSource.PROGRAMMERS).contains("def solution")) + } + + @Test + fun `Rust Go Ruby are not submittable to SWEA`() { + assertTrue(Language.RUST.sweaId < 0) + assertTrue(Language.GO.sweaId < 0) + assertTrue(Language.RUBY.sweaId < 0) + } + + @Test + fun `isSubmittable matrix matches platform support`() { + // 리트코드/코드포스는 전 언어 제출 가능 + for (lang in Language.entries) { + assertTrue(lang.isSubmittable(ProblemSource.LEETCODE)) + assertTrue(lang.isSubmittable(ProblemSource.CODEFORCES)) + } + // 프로그래머스는 Rust만 미지원 + assertFalse(Language.RUST.isSubmittable(ProblemSource.PROGRAMMERS)) + assertTrue(Language.GO.isSubmittable(ProblemSource.PROGRAMMERS)) + assertTrue(Language.RUBY.isSubmittable(ProblemSource.PROGRAMMERS)) + // SWEA는 sweaId 기반 + assertTrue(Language.JAVA.isSubmittable(ProblemSource.SWEA)) + assertFalse(Language.KOTLIN.isSubmittable(ProblemSource.SWEA)) + assertFalse(Language.RUST.isSubmittable(ProblemSource.SWEA)) } @Test diff --git a/src/test/kotlin/com/codingtestkit/service/CodeRunnerTest.kt b/src/test/kotlin/com/codingtestkit/service/CodeRunnerTest.kt index 28b9742..c9cdb96 100644 --- a/src/test/kotlin/com/codingtestkit/service/CodeRunnerTest.kt +++ b/src/test/kotlin/com/codingtestkit/service/CodeRunnerTest.kt @@ -426,4 +426,169 @@ class CodeRunnerTest { // The caller's arguments must be preserved at the end of the command. assertEquals(listOf("-cp", "X", "Main"), cmd.takeLast(3)) } + + // ── Rust / Go / Ruby (이슈 #7) ── + + private fun isToolAvailable(vararg command: String): Boolean { + return try { + ProcessBuilder(*command).start().waitFor() == 0 + } catch (_: Exception) { false } + } + + @Test + fun `hasMainFunction detects Rust Go Ruby main`() { + val method = CodeRunner::class.java.getDeclaredMethod( + "hasMainFunction", String::class.java, Language::class.java + ) + method.isAccessible = true + + assertTrue(method.invoke(CodeRunner, "fn main() { }", Language.RUST) as Boolean) + assertFalse(method.invoke(CodeRunner, "fn solution() -> i32 { 0 }", Language.RUST) as Boolean) + assertTrue(method.invoke(CodeRunner, "func main() { }", Language.GO) as Boolean) + assertFalse(method.invoke(CodeRunner, "func solution() int { return 0 }", Language.GO) as Boolean) + // Ruby는 stdin 사용 여부로 스크립트 스타일 판별 + assertTrue(method.invoke(CodeRunner, "n = gets.to_i", Language.RUBY) as Boolean) + assertFalse(method.invoke(CodeRunner, "def solution()\n 0\nend", Language.RUBY) as Boolean) + } + + @Test + fun `run Rust hello world`() { + if (!isToolAvailable("rustc", "--version")) return + + val code = """ + use std::io::{self, Read}; + + fn main() { + let mut input = String::new(); + io::stdin().read_to_string(&mut input).unwrap(); + let mut it = input.split_whitespace(); + let a: i64 = it.next().unwrap().parse().unwrap(); + let b: i64 = it.next().unwrap().parse().unwrap(); + println!("{}", a + b); + } + """.trimIndent() + + val tc = TestCase(input = "1 2", expectedOutput = "3") + val result = CodeRunner.run(code, Language.RUST, tc) + + assertEquals(0, result.exitCode, "Exit code should be 0: ${result.error}") + assertEquals("3", result.output.trim()) + } + + @Test + fun `run Go hello world`() { + if (!isToolAvailable("go", "version")) return + + val code = """ + package main + + import "fmt" + + func main() { + var a, b int + fmt.Scan(&a, &b) + fmt.Println(a + b) + } + """.trimIndent() + + val tc = TestCase(input = "3 4", expectedOutput = "7") + val result = CodeRunner.run(code, Language.GO, tc) + + assertEquals(0, result.exitCode, "Exit code should be 0: ${result.error}") + assertEquals("7", result.output.trim()) + } + + @Test + fun `run Ruby hello world`() { + if (!isToolAvailable("ruby", "--version")) return + + val code = """ + a, b = gets.split.map(&:to_i) + puts a + b + """.trimIndent() + + val tc = TestCase(input = "5 6", expectedOutput = "11") + val result = CodeRunner.run(code, Language.RUBY, tc) + + assertEquals(0, result.exitCode, "Exit code should be 0: ${result.error}") + assertEquals("11", result.output.trim()) + } + + @Test + fun `run Rust programmers wrapper with vec argument`() { + if (!isToolAvailable("rustc", "--version")) return + + val code = """ + fn solution(arr: Vec) -> i32 { + arr.iter().sum() + } + """.trimIndent() + + val tc = TestCase(input = "[1,2,3]", expectedOutput = "6") + val result = CodeRunner.runProgrammers(code, Language.RUST, tc, listOf("arr")) + + assertEquals(0, result.exitCode, "Exit code should be 0: ${result.error}") + assertEquals("6", result.output.trim()) + } + + @Test + fun `run Go programmers wrapper returns compact array`() { + if (!isToolAvailable("go", "version")) return + + val code = """ + func solution(arr []int) []int { + doubled := make([]int, len(arr)) + for i, v := range arr { + doubled[i] = v * 2 + } + return doubled + } + """.trimIndent() + + val tc = TestCase(input = "[1,2,3]", expectedOutput = "[2,4,6]") + val result = CodeRunner.runProgrammers(code, Language.GO, tc, listOf("arr")) + + assertEquals(0, result.exitCode, "Exit code should be 0: ${result.error}") + assertEquals("[2,4,6]", result.output.trim()) + } + + @Test + fun `run Ruby programmers wrapper redirects user output`() { + if (!isToolAvailable("ruby", "--version")) return + + val code = """ + def solution(arr) + puts "debug output" + arr.map { |x| x * 2 } + end + """.trimIndent() + + val tc = TestCase(input = "[1, 2, 3]", expectedOutput = "[2,4,6]") + val result = CodeRunner.runProgrammers(code, Language.RUBY, tc, listOf("arr")) + + assertEquals(0, result.exitCode, "Exit code should be 0: ${result.error}") + // 사용자 puts는 stderr로 리다이렉트되고 stdout에는 리턴값만 출력 + assertEquals("[2,4,6]", result.output.trim()) + } + + /** + * 빈 입력이어도 자식 프로세스의 stdin이 닫혀 EOF가 전달되어야 한다. + * 닫지 않으면 stdin을 읽는 코드가 타임아웃까지 블로킹되어 허위 시간 초과가 발생한다. + */ + @Test + fun `run with blank input closes stdin so child sees EOF`() { + if (!isPythonAvailable()) return + + val code = """ + import sys + print(len(sys.stdin.read())) + """.trimIndent() + + val tc = TestCase(input = "", expectedOutput = "0") + val result = CodeRunner.run(code, Language.PYTHON, tc, timeoutSeconds = 3) + + assertFalse(result.timedOut, "Should not time out on blank input: ${result.error}") + assertEquals(0, result.exitCode, "Exit code should be 0: ${result.error}") + assertEquals("0", result.output.trim()) + } } diff --git a/src/test/kotlin/com/codingtestkit/service/LeetCodeStyleSmokeTest.kt b/src/test/kotlin/com/codingtestkit/service/LeetCodeStyleSmokeTest.kt new file mode 100644 index 0000000..4862242 --- /dev/null +++ b/src/test/kotlin/com/codingtestkit/service/LeetCodeStyleSmokeTest.kt @@ -0,0 +1,120 @@ +package com.codingtestkit.service + +import com.codingtestkit.model.Language +import com.codingtestkit.model.TestCase +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +/** + * 리트코드가 실제로 주는 초기 코드 형태(two_sum/twoSum 네이밍, impl Solution 등)가 + * 프로그래머스식 래퍼(runProgrammers)를 올바르게 통과하는지 검증. + * 입력은 실제 exampleTestcases 포맷(파라미터별 한 줄)을 사용한다. + * 해당 툴체인이 없는 환경에서는 조용히 스킵된다. + */ +class LeetCodeStyleSmokeTest { + + private fun available(vararg cmd: String) = try { + ProcessBuilder(*cmd).start().waitFor() == 0 + } catch (_: Exception) { false } + + // 리트코드 exampleTestcases 포맷: 파라미터별 한 줄 + private val tc = TestCase(input = "[2,7,11,15]\n9", expectedOutput = "[0,1]") + + @Test + fun `Rust leetcode impl Solution style`() { + if (!available("rustc", "--version")) return + // 리트코드 Rust 초기 코드 형태 그대로 (struct Solution 선언 없음 → 래퍼가 보충해야 함) + val code = """ + impl Solution { + pub fn two_sum(nums: Vec, target: i32) -> Vec { + for i in 0..nums.len() { + for j in (i + 1)..nums.len() { + if nums[i] + nums[j] == target { + return vec![i as i32, j as i32]; + } + } + } + vec![] + } + } + """.trimIndent() + val result = CodeRunner.runProgrammers(code, Language.RUST, tc, listOf("nums", "target")) + assertEquals(0, result.exitCode, "compile/run failed: ${result.error}") + assertEquals("[0,1]", result.output.trim()) + } + + @Test + fun `Go leetcode twoSum style`() { + if (!available("go", "version")) return + // 리트코드 Go 초기 코드 형태 그대로 (package 선언 없음, 함수명 twoSum) + val code = """ + func twoSum(nums []int, target int) []int { + for i := 0; i < len(nums); i++ { + for j := i + 1; j < len(nums); j++ { + if nums[i]+nums[j] == target { + return []int{i, j} + } + } + } + return nil + } + """.trimIndent() + val result = CodeRunner.runProgrammers(code, Language.GO, tc, listOf("nums", "target")) + assertEquals(0, result.exitCode, "compile/run failed: ${result.error}") + assertEquals("[0,1]", result.output.trim()) + } + + @Test + fun `Ruby leetcode two_sum style`() { + if (!available("ruby", "--version")) return + // 리트코드 Ruby 초기 코드 형태 그대로 + val code = """ + # @param {Integer[]} nums + # @param {Integer} target + # @return {Integer[]} + def two_sum(nums, target) + nums.each_with_index do |a, i| + nums.each_with_index do |b, j| + return [i, j] if i < j && a + b == target + end + end + [] + end + """.trimIndent() + val result = CodeRunner.runProgrammers(code, Language.RUBY, tc, listOf("nums", "target")) + assertEquals(0, result.exitCode, "run failed: ${result.error}") + assertEquals("[0,1]", result.output.trim()) + } + + @Test + fun `String and 2D array literal conversion`() { + if (!available("go", "version")) return + // 문자열 파라미터 + 문자열 리턴 + val code = """ + func greet(name string) string { + return "hi " + name + } + """.trimIndent() + val strTc = TestCase(input = "\"world\"", expectedOutput = "\"hi world\"") + val result = CodeRunner.runProgrammers(code, Language.GO, strTc, listOf("name")) + assertEquals(0, result.exitCode, "run failed: ${result.error}") + assertEquals("\"hi world\"", result.output.trim()) + + // 2차원 배열 파라미터 + val code2 = """ + func sumAll(grid [][]int) int { + s := 0 + for _, row := range grid { + for _, v := range row { + s += v + } + } + return s + } + """.trimIndent() + val gridTc = TestCase(input = "[[1,2],[3,4]]", expectedOutput = "10") + val result2 = CodeRunner.runProgrammers(code2, Language.GO, gridTc, listOf("grid")) + assertEquals(0, result2.exitCode, "run failed: ${result2.error}") + assertEquals("10", result2.output.trim()) + } +}