UNPKG

leetkick

Version:

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

527 lines (463 loc) 15.2 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: string): string { // 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: string | undefined, options: {language?: string}) => { 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: string, problem: string, ): Promise<string | null> { 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: string, ): Promise<{title: string; slug: string} | null> { 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: string, ): Promise<{title: string; slug: string} | null> { 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: string): string { return title .toLowerCase() .replace(/[^a-zA-Z0-9\s]/g, '') .trim() .replace(/\s+/g, '-'); } function matchesProblemSlug( searchTerm: string, problemInfo: {title: string; slug: string}, ): boolean { 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: string, problemDir: string, language: string, ): Promise<boolean> { return new Promise((resolve, reject) => { let command: string; let args: string[]; 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: string, language: string, ): Promise<void> { return new Promise((resolve, reject) => { let command: string; let args: string[]; 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: string): Promise<void> { 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}`, ); } }