diff --git a/Cargo.lock b/Cargo.lock index 137c2b4..1fc8830 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1455,6 +1455,7 @@ dependencies = [ "tauri-plugin-http", "tauri-plugin-opener", "tauri-plugin-store", + "tempfile", "thiserror 2.0.17", "tokio", "tokio-native-tls", diff --git a/apps/executeJS/src-tauri/Cargo.toml b/apps/executeJS/src-tauri/Cargo.toml index e830059..7b310da 100644 --- a/apps/executeJS/src-tauri/Cargo.toml +++ b/apps/executeJS/src-tauri/Cargo.toml @@ -43,6 +43,7 @@ native-tls = "0.2" tokio-rustls = "0.26" tokio-stream = "0.1" flate2 = "1.1" +tempfile = "3.23.0" # JavaScript 런타임 의존성 (Deno Core) deno-runtime = { path = "../../../crates/deno-runtime" } diff --git a/apps/executeJS/src-tauri/src/commands.rs b/apps/executeJS/src-tauri/src/commands.rs index dd8928e..e632eea 100644 --- a/apps/executeJS/src-tauri/src/commands.rs +++ b/apps/executeJS/src-tauri/src/commands.rs @@ -1,5 +1,6 @@ use crate::js_executor::{execute_javascript_code, JsExecutionResult}; use serde::{Deserialize, Serialize}; +use std::process::Command; #[derive(Debug, Serialize, Deserialize)] pub struct AppInfo { @@ -9,6 +10,57 @@ pub struct AppInfo { pub author: String, } +// 린트 결과를 나타내는 구조체 +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LintSeverity { + Error, + Warning, + Info, + Hint, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct LintResult { + pub line: usize, + pub column: usize, + pub end_line: usize, + pub end_column: usize, + pub message: String, + pub severity: LintSeverity, + pub rule_id: String, +} + +// oxlint JSON 출력 형식에 맞는 구조체 +#[derive(Debug, Deserialize)] +struct OxlintOutput { + diagnostics: Vec, +} + +// oxlint 진단 정보 +#[derive(Debug, Deserialize)] +struct OxlintDiagnostic { + message: String, + code: String, + severity: String, + labels: Vec, +} + +// oxlint 레이블 (위치 정보 포함) +#[derive(Debug, Deserialize)] +struct OxlintLabel { + span: OxlintSpan, +} + +// oxlint 스팬 (라인, 컬럼, 길이 정보) +#[derive(Debug, Deserialize)] +struct OxlintSpan { + line: usize, + column: usize, + #[serde(default)] + length: usize, +} + #[tauri::command] pub async fn execute_js(code: &str) -> Result { let result = execute_javascript_code(code).await; @@ -39,3 +91,151 @@ pub fn get_app_info() -> AppInfo { author: "ExecuteJS Team".to_string(), } } + +// JavaScript 코드를 oxlint로 린트하고 결과를 반환 +#[tauri::command] +pub async fn lint_code(code: String) -> Result, String> { + use std::io::Write; + use tempfile::NamedTempFile; + + // 임시 파일 생성 + let mut temp_file = NamedTempFile::with_suffix(".js") + .map_err(|e| format!("Failed to create temp file: {}", e))?; + + temp_file + .write_all(code.as_bytes()) + .map_err(|e| format!("Failed to write to temp file: {}", e))?; + + let temp_path = temp_file.path().to_str().ok_or("Invalid temp path")?; + + // 프로젝트 루트 경로 찾기 (현재 실행 파일 위치 기준) + let current_exe = + std::env::current_exe().map_err(|e| format!("Failed to get current exe path: {}", e))?; + + // Tauri 앱의 경우: target/debug/executeJS 또는 target/release/executeJS + // 프로젝트 루트는 3단계 위 (target/debug 또는 target/release) + let mut project_root = current_exe + .parent() // target/debug 또는 target/release + .and_then(|p| p.parent()) // target + .and_then(|p| p.parent()) // 프로젝트 루트 + .ok_or("Failed to find project root")?; + + // 개발 모드에서는 src-tauri가 있으므로 한 단계 더 올라가야 함 + let src_tauri_path = project_root.join("apps/executeJS/src-tauri"); + if src_tauri_path.exists() { + project_root = src_tauri_path + .parent() + .and_then(|p| p.parent()) + .ok_or("Failed to find project root")?; + } + + // oxlint 경로: 프로젝트 루트의 node_modules/.bin/oxlint + let oxlint_path = project_root + .join("node_modules") + .join(".bin") + .join("oxlint"); + + // oxlint 실행 (로컬 설치된 버전 사용) + let output = if oxlint_path.exists() { + // 로컬 설치된 oxlint 사용 + Command::new(oxlint_path) + .arg("--format") + .arg("json") + .arg(temp_path) + .output() + .map_err(|e| format!("Failed to execute oxlint: {}", e))? + } else { + // fallback: pnpm exec oxlint (pnpm이 설치되어 있다면) + Command::new("pnpm") + .arg("exec") + .arg("oxlint") + .arg("--format") + .arg("json") + .arg(temp_path) + .current_dir(project_root) + .output() + .map_err(|e| format!("Failed to execute oxlint: {}", e))? + }; + + drop(temp_file); + + // oxlint 출력 파싱 + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + let results = parse_oxlint_output(&stdout, &stderr); + + Ok(results) +} + +// oxlint의 JSON 출력을 파싱하여 LintResult 벡터로 변환 +fn parse_oxlint_output(stdout: &str, stderr: &str) -> Vec { + let output_text = if stdout.trim().is_empty() { + stderr + } else { + stdout + }; + + // JSON 형식으로 직접 deserialize + match serde_json::from_str::(output_text) { + Ok(oxlint_output) => { + let mut results = Vec::new(); + + for diagnostic in oxlint_output.diagnostics { + // labels의 첫 번째 항목에서 위치 정보 가져오기 + if let Some(label) = diagnostic.labels.first() { + let span = &label.span; + let line = span.line; + let column = span.column.max(1); + let end_column = if span.length > 0 { + column + span.length + } else { + // length가 없을 경우 최소값 사용 + column + 1 + }; + + // code에서 rule_id 추출 + // 예: "eslint(no-unused-vars)" -> "no-unused-vars" + let rule_id = if diagnostic.code.starts_with("eslint(") { + diagnostic + .code + .strip_prefix("eslint(") + .and_then(|s| s.strip_suffix(')')) + .unwrap_or("unknown") + .to_string() + } else { + diagnostic.code.clone() + }; + + // severity 변환 + let severity = match diagnostic.severity.as_str() { + "error" => LintSeverity::Error, + "warning" => LintSeverity::Warning, + "info" => LintSeverity::Info, + "hint" => LintSeverity::Hint, + _ => LintSeverity::Warning, // 기본값 + }; + + results.push(LintResult { + line, + column, + end_line: line, + end_column, + message: diagnostic.message, + severity, + rule_id, + }); + } + } + + results + } + Err(e) => { + // 에러 로깅 추가 + eprintln!("Failed to parse oxlint output: {}", e); + eprintln!("stdout: {}", stdout); + eprintln!("stderr: {}", stderr); + Vec::new() + } + } +} diff --git a/apps/executeJS/src-tauri/src/lib.rs b/apps/executeJS/src-tauri/src/lib.rs index e06a972..9d55f52 100644 --- a/apps/executeJS/src-tauri/src/lib.rs +++ b/apps/executeJS/src-tauri/src/lib.rs @@ -49,7 +49,11 @@ pub fn run() { tracing::info!("ExecuteJS 애플리케이션이 종료됩니다."); } }) - .invoke_handler(tauri::generate_handler![execute_js, get_app_info]) + .invoke_handler(tauri::generate_handler![ + execute_js, + get_app_info, + lint_code + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/apps/executeJS/src/shared/types/index.ts b/apps/executeJS/src/shared/types/index.ts index 4d9fd28..3b37fe5 100644 --- a/apps/executeJS/src/shared/types/index.ts +++ b/apps/executeJS/src/shared/types/index.ts @@ -20,3 +20,15 @@ export interface OutputPanelProps { result: JsExecutionResult | null; isExecuting: boolean; } + +export type LintSeverity = 'error' | 'warning' | 'info' | 'hint'; + +export interface LintResult { + line: number; + column: number; + end_line: number; + end_column: number; + message: string; + severity: LintSeverity; + rule_id: string; +} diff --git a/apps/executeJS/src/widgets/code-editor/code-editor.tsx b/apps/executeJS/src/widgets/code-editor/code-editor.tsx index b0388ab..046a339 100644 --- a/apps/executeJS/src/widgets/code-editor/code-editor.tsx +++ b/apps/executeJS/src/widgets/code-editor/code-editor.tsx @@ -1,13 +1,14 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; -import { Editor, EditorProps } from '@monaco-editor/react'; +import { invoke } from '@tauri-apps/api/core'; +import { Editor, EditorProps, Monaco } from '@monaco-editor/react'; import type { Options as PrettierOptions } from 'prettier'; import prettier from 'prettier/standalone'; import babel from 'prettier/plugins/babel'; import estree from 'prettier/plugins/estree'; import typescript from 'prettier/plugins/typescript'; -import type { CodeEditorProps } from '../../shared/types'; +import { CodeEditorProps, LintResult, LintSeverity } from '@/shared'; const prettierOptions: PrettierOptions = { semi: true, @@ -18,6 +19,23 @@ const prettierOptions: PrettierOptions = { useTabs: false, }; +const severityToMarkerSeverity = (severity: LintSeverity, monaco: Monaco) => { + switch (severity) { + case 'error': + return monaco.MarkerSeverity.Error; + case 'warning': + return monaco.MarkerSeverity.Warning; + case 'info': + return monaco.MarkerSeverity.Info; + case 'hint': + return monaco.MarkerSeverity.Hint; + default: + // 타입 체크로 도달 불가능하지만, 런타임 안전을 위해 + console.warn(`Unknown severity: ${severity}, defaulting to Warning`); + return monaco.MarkerSeverity.Warning; + } +}; + export const CodeEditor: React.FC = ({ value, onChange, @@ -26,12 +44,76 @@ export const CodeEditor: React.FC = ({ theme = 'vs-dark', }) => { const editorRef = useRef(null); + const monacoRef = useRef(null); const disposablesRef = useRef>([]); + const debounceTimeoutRef = useRef(null); + + const validateCode = useCallback(async (model: any, version: number) => { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + + debounceTimeoutRef.current = setTimeout(async () => { + if (!model || !monacoRef.current) return; + + try { + const code = model.getValue(); + + // Tauri 백엔드에서 oxlint 실행 + const lintResults = await invoke>('lint_code', { + code, + }); + + // setModelMarkers 사용 + const monaco = monacoRef.current; + if (monaco && model.getVersionId() === version) { + const markers = lintResults.map((result) => { + // Monaco는 1-based 인덱스 사용 + const startColumn = Math.max(1, result.column); + const endColumn = Math.max(startColumn + 1, result.end_column); + + // severity를 소문자로 비교하여 MarkerSeverity enum 사용 + const severity = severityToMarkerSeverity(result.severity, monaco); + + return { + message: `${result.message} (${result.rule_id})`, + severity, + startLineNumber: result.line, + startColumn: startColumn, + endLineNumber: result.end_line, + endColumn: endColumn, + source: 'oxlint', + code: result.rule_id, + }; + }); + + monaco.editor.setModelMarkers(model, 'oxlint', markers); + } + } catch (error) { + console.error('oxlint validation error:', error); + // 에러 발생 시 마커 초기화 + const monaco = monacoRef.current; + if (monaco) { + const model = editorRef.current?.getModel(); + if (model && model.getVersionId() === version) { + monaco.editor.setModelMarkers(model, 'oxlint', []); + } + } + } + }, 500); + }, []); // Monaco Editor 설정 const handleEditorDidMount: EditorProps['onMount'] = (editor, monaco) => { try { editorRef.current = editor; + monacoRef.current = monaco; + + // 기본 TypeScript validator 비활성화 (oxlint 사용 시) + monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({ + noSemanticValidation: true, + noSyntaxValidation: true, + }); // 이전 등록된 포맷터가 남아있는 경우, 먼저 해제 if (disposablesRef.current.length > 0) { @@ -113,6 +195,32 @@ export const CodeEditor: React.FC = ({ ); } + const model = editor.getModel(); + + if (model) { + // 모델 변경 시 validation + const contentChangeDisposable = model.onDidChangeContent(() => { + // Reset the markers + monaco.editor.setModelMarkers(model, 'oxlint', []); + + // Send the code to the backend for validation + validateCode(model, model.getVersionId()); + }); + + // model이 있는 경우 포맷터 + 이벤트 리스너 저장 + disposablesRef.current = [ + jsDisposable, + tsDisposable, + contentChangeDisposable, + ]; + + // 초기 validation + validateCode(model, model.getVersionId()); + } else { + // model이 없는 경우 포맷터만 저장 + disposablesRef.current = [jsDisposable, tsDisposable]; + } + // 에디터 포커스 editor.focus(); } catch (error) { @@ -165,6 +273,9 @@ export const CodeEditor: React.FC = ({ // Cleanup: unmount 시 포맷터 등록 해제 useEffect(() => { return () => { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } // 모든 disposable 해제 disposablesRef.current.forEach((disposable) => { disposable.dispose();