UNPKG

leetkick

Version:

A CLI tool for scaffolding LeetCode exercises with language-specific testing setups

436 lines 16.8 kB
import { Command } from 'commander'; import { findWorkspaceRoot } from '../utils/workspace.js'; import { getAvailableLanguages } from '../utils/templates.js'; import { existsSync } from 'fs'; import { join } from 'path'; import { spawn } from 'child_process'; import { execSync } from 'child_process'; import { readdir, readFile } from 'fs/promises'; function findPythonCommand(languageDir) { // 1. Try virtual environment first const venvPython = join(languageDir, 'venv', 'bin', 'python'); const venvPython3 = join(languageDir, 'venv', 'bin', 'python3'); if (existsSync(venvPython)) return venvPython; if (existsSync(venvPython3)) return venvPython3; // 2. Try system commands in order of preference const pythonCommands = ['python3', 'python']; for (const cmd of pythonCommands) { try { execSync(`which ${cmd}`, { stdio: 'ignore' }); return cmd; } catch { // Command not found, continue } } // 3. Fallback - let spawn handle the error return 'python3'; } export const testCommand = new Command('test') .description('Run tests for a LeetCode problem or all problems') .argument('[problem]', 'Problem number (e.g., "1") or slug (e.g., "two-sum"). If not provided, runs all tests.') .option('-l, --language <language>', 'Programming language') .action(async (problem, options) => { try { const workspaceRoot = findWorkspaceRoot(); if (!workspaceRoot) { console.log('No leetkick workspace found. Run "leetkick init" first.'); console.log('Make sure you are in a directory that contains .leetkick.json or run the command from within a leetkick workspace.'); return; } const availableLanguages = await getAvailableLanguages(); if (!options.language) { console.log('Available languages:', availableLanguages.join(', ')); throw new Error('Please specify a language with --language <lang>'); } if (!availableLanguages.includes(options.language)) { console.log('Available languages:', availableLanguages.join(', ')); throw new Error(`Language '${options.language}' not supported.`); } const languageDir = join(workspaceRoot, options.language); if (!existsSync(languageDir)) { throw new Error(`${options.language} workspace not found. Run "leetkick add ${options.language}" first.`); } if (problem) { // Find the specific problem directory const problemDir = await findProblemDirectory(languageDir, problem); if (!problemDir) { throw new Error(`Problem '${problem}' not found in ${options.language} workspace.`); } console.log(`Running tests for: ${problemDir}...`); const testPassed = await runTests(languageDir, problemDir, options.language); if (!testPassed) { throw new Error('Tests failed'); } } else { // Run all tests console.log(`Running all tests for ${options.language}...`); await runAllTests(languageDir, options.language); } } catch (error) { console.error('Error:', error instanceof Error ? error.message : error); throw error; } }); async function findProblemDirectory(languageDir, problem) { try { // For Kotlin and Java, we need to check under src/main/{language} const isKotlin = languageDir.endsWith('kotlin'); const isJava = languageDir.endsWith('java'); const isRust = languageDir.endsWith('rust'); // For Rust, problems are files in src/ directory, not separate directories if (isRust) { const srcDir = join(languageDir, 'src'); if (!existsSync(srcDir)) { return null; } const entries = await readdir(srcDir); const problemFiles = entries.filter(file => file.startsWith('problem_') && file.endsWith('.rs')); // Try to match by problem number const paddedProblem = problem.padStart(4, '0'); const targetFile = `problem_${paddedProblem}.rs`; if (problemFiles.includes(targetFile)) { return `problem_${paddedProblem}`; } // Try exact match const exactFile = `${problem}.rs`; if (problemFiles.includes(exactFile)) { return problem; } // For numeric search if (/^\d+$/.test(problem)) { const numericMatch = problemFiles.find(file => file.includes(paddedProblem)); if (numericMatch) { return numericMatch.replace('.rs', ''); } } // For slug matching, read problem metadata from files for (const file of problemFiles) { const filePath = join(srcDir, file); const problemInfo = await extractProblemInfoFromFile(filePath); if (problemInfo && matchesProblemSlug(problem, problemInfo)) { return file.replace('.rs', ''); } } return null; } const isPython = languageDir.endsWith('python'); const searchDir = isKotlin || isJava ? join(languageDir, 'src', 'main', isKotlin ? 'kotlin' : 'java') : isPython ? join(languageDir, 'src') : languageDir; if (!existsSync(searchDir)) { return null; } const entries = await readdir(searchDir, { withFileTypes: true }); const directories = entries .filter(entry => entry.isDirectory()) .map(entry => entry.name); // Try exact match first if (directories.includes(problem)) { return problem; } // Try to match by problem number (e.g., "1" matches "problem_0001" or "problem0001" for Kotlin) const paddedProblem = problem.padStart(4, '0'); const byNumber = directories.find(dir => dir === `problem_${paddedProblem}` || dir === `problem${paddedProblem}`); if (byNumber) { return byNumber; } // For backward compatibility and flexible matching, also check if it's a number // and find any directory containing that number if (/^\d+$/.test(problem)) { const numericMatch = directories.find(dir => dir.includes(paddedProblem)); if (numericMatch) { return numericMatch; } } // For slug matching, read problem metadata from files for (const dir of directories) { if (dir.startsWith('problem')) { const problemInfo = await extractProblemInfoFromDirectory(join(searchDir, dir)); if (problemInfo && matchesProblemSlug(problem, problemInfo)) { return dir; } } } return null; } catch (error) { return null; } } async function extractProblemInfoFromFile(filePath) { try { const content = await readFile(filePath, 'utf-8'); // Extract problem info from comment header // C-style comments: /* * [1] Title */ let titleMatch = content.match(/\* \[\d+\] (.+)/); // Python-style comments: """ [1] Title """ if (!titleMatch) { titleMatch = content.match(/\[\d+\] (.+)/); } if (titleMatch) { const title = titleMatch[1]; const slug = titleToSlug(title); return { title, slug }; } return null; } catch (error) { return null; } } async function extractProblemInfoFromDirectory(problemDir) { try { const files = await readdir(problemDir); // Look for any files that might contain problem metadata for (const file of files) { if (file === 'catch_amalgamated.hpp') { continue; } const filePath = join(problemDir, file); const content = await readFile(filePath, 'utf-8'); // Extract problem info from comment header // C-style comments: /* * [1] Title */ let titleMatch = content.match(/\* \[\d+\] (.+)/); // Python-style comments: """ [1] Title """ if (!titleMatch) { titleMatch = content.match(/\[\d+\] (.+)/); } if (titleMatch) { const title = titleMatch[1]; const slug = titleToSlug(title); return { title, slug }; } } return null; } catch (error) { return null; } } function titleToSlug(title) { return title .toLowerCase() .replace(/[^a-zA-Z0-9\s]/g, '') .trim() .replace(/\s+/g, '-'); } function matchesProblemSlug(searchTerm, problemInfo) { const normalizedSearch = searchTerm.toLowerCase().replace(/_/g, '-'); const normalizedSlug = problemInfo.slug.toLowerCase(); const normalizedTitle = problemInfo.title.toLowerCase(); return (normalizedSlug === normalizedSearch || normalizedSlug.includes(normalizedSearch) || normalizedTitle.includes(normalizedSearch.replace(/-/g, ' '))); } async function runTests(languageDir, problemDir, language) { return new Promise((resolve, reject) => { let command; let args; switch (language) { case 'typescript': command = 'npx'; args = ['vitest', 'run', `${problemDir}`]; break; case 'javascript': command = 'node'; args = ['--test', `${problemDir}/*.test.js`]; break; case 'python': command = findPythonCommand(languageDir); args = ['-m', 'pytest', `tests/${problemDir}/`, '-v']; break; case 'java': { // Use Gradle to run tests for specific package // Check if gradlew exists, otherwise fall back to gradle const gradlewPath = join(languageDir, 'gradlew'); if (existsSync(gradlewPath)) { command = './gradlew'; } else { command = 'gradle'; } args = ['test', '--tests', `${problemDir}.*`]; break; } case 'go': // Run go test in the specific problem directory command = 'sh'; args = ['-c', `cd "${problemDir}" && go test -v`]; break; case 'rust': // Run cargo test for specific module in workspace command = 'cargo'; args = ['test', `${problemDir}::`]; break; case 'cpp': // Compile and run C++ test directly with include path for shared headers command = 'sh'; args = [ '-c', `cd "${problemDir}" && g++ -I.. -std=c++17 *.test.cpp -o test_runner && ./test_runner`, ]; break; case 'kotlin': { // Use Gradle to run tests for specific package // Check if gradlew exists, otherwise fall back to gradle const gradlewPath = join(languageDir, 'gradlew'); if (existsSync(gradlewPath)) { command = './gradlew'; } else { command = 'gradle'; } args = ['test', '--tests', `${problemDir}.*`]; break; } default: reject(new Error(`Testing not implemented for language: ${language}`)); return; } const env = { ...process.env }; if (language === 'python') { env.PYTHONPATH = 'src'; } const child = spawn(command, args, { cwd: languageDir, stdio: 'inherit', env, }); child.on('close', code => { if (code === 0) { console.log('✓ Tests passed!'); resolve(true); } else { // For test failures, don't reject - the test output already shows what failed console.log(`❌ Tests failed (exit code ${code || 1})`); resolve(false); // Return false to indicate failure } }); child.on('error', error => { reject(new Error(`Failed to run tests: ${error.message}`)); }); }); } async function runAllTests(languageDir, language) { return new Promise((resolve, reject) => { let command; let args; switch (language) { case 'typescript': command = 'npx'; args = ['vitest', 'run']; break; case 'javascript': command = 'node'; args = ['--test', '**/*.test.js']; break; case 'python': command = findPythonCommand(languageDir); args = ['-m', 'pytest', '-v']; break; case 'java': { // Use Gradle to run all tests const gradlewPath = join(languageDir, 'gradlew'); if (existsSync(gradlewPath)) { command = './gradlew'; } else { command = 'gradle'; } args = ['test']; break; } case 'go': command = 'go'; args = ['test', './...']; break; case 'rust': command = 'cargo'; args = ['test']; break; case 'cpp': { // For C++, we need to find all problem directories and run each test // This is more complex, so we'll handle it specially runAllCppTests(languageDir).then(resolve).catch(reject); return; } case 'kotlin': { // Use Gradle to run all tests const gradlewPath = join(languageDir, 'gradlew'); if (existsSync(gradlewPath)) { command = './gradlew'; } else { command = 'gradle'; } args = ['test']; break; } default: reject(new Error(`Testing not implemented for language: ${language}`)); return; } const env = { ...process.env }; if (language === 'python') { env.PYTHONPATH = 'src'; } const child = spawn(command, args, { cwd: languageDir, stdio: 'inherit', env, }); child.on('close', code => { if (code === 0) { console.log('✓ All tests passed!'); resolve(); } else { console.log(`❌ Some tests failed (exit code ${code || 1})`); reject(new Error('Some tests failed')); } }); child.on('error', error => { reject(new Error(`Failed to run tests: ${error.message}`)); }); }); } async function runAllCppTests(languageDir) { try { const entries = await readdir(languageDir, { withFileTypes: true }); const problemDirs = entries .filter(entry => entry.isDirectory() && entry.name.startsWith('problem')) .map(entry => entry.name); if (problemDirs.length === 0) { console.log('No C++ problems found to test.'); return; } console.log(`Found ${problemDirs.length} C++ problem(s) to test...`); let allPassed = true; for (const problemDir of problemDirs) { console.log(`\nTesting ${problemDir}...`); const testPassed = await runTests(languageDir, problemDir, 'cpp'); if (!testPassed) { allPassed = false; } } if (allPassed) { console.log('\n✓ All C++ tests passed!'); } else { console.log('\n❌ Some C++ tests failed'); throw new Error('Some C++ tests failed'); } } catch (error) { throw new Error(`Failed to run all C++ tests: ${error instanceof Error ? error.message : error}`); } } //# sourceMappingURL=test.js.map