diff --git a/src/shell/command.ts b/src/shell/command.ts index 261b6bf..84194da 100644 --- a/src/shell/command.ts +++ b/src/shell/command.ts @@ -1,642 +1,150 @@ -import { BJShell } from "." -import fs from "fs/promises" -import { existsSync } from "fs" -import chalk from "chalk" -import conf from '@/config' -import { spawn, exec, spawnSync } from 'child_process' -import { getLanguages, getProblem, getProblemSet, language, problem, setLanguageCommentMark } from '@/net/parse' -import { parseTestCasesFromLocal, readTemplateFromLocal } from '@/storage/filereader' -import { writeFile, writeMDFile, writeMainTmp } from '@/storage/filewriter' -import { table } from 'table' -import chokidar from 'chokidar' -import { loadFromLocal, saveToLocal } from "@/storage/localstorage" - -interface Command { - desc: string - alias?: string - func: () => Promise | void - important?: boolean +import { BJShell } from "@/shell"; +import cd from "./commands/cd"; +import help from "./commands/help"; +import lang from "./commands/lang"; +import logout from "./commands/logout"; +import ls from "./commands/ls"; +import test from "./commands/test"; +import probset from "./commands/probset"; +import set from "./commands/set"; +import show from "./commands/show"; +import submit from "./commands/submit"; +import exec from "./commands/exec"; +import testwatch from "./commands/testwatch"; + +export interface Command { + desc: string; + alias?: string; + func: () => Promise | void; + important?: boolean; } -export default function acquireAllCommands(that: BJShell, cmd: string, arg: string[]): { [key: string]: Command } { - - async function ls() { - try { - const files = await fs.readdir(process.cwd()) - let output = "" - for (const file of files) { - const isDir = (await fs.stat(file)).isDirectory() - output += `${isDir ? chalk.blue(file) : file} ` - } - console.log(output) - } catch (e) { - if (e instanceof Error) console.log(e.message) - else console.log(e) - } - } - - function cd() { - try { - const path = arg[0] ?? "" - process.chdir(path) - } catch (e) { - if (e instanceof Error) console.log(e.message) - else console.log(e) - } - } - - async function logout() { - await that.user.setToken("") - await that.user.setAutologin("") - that.setLoginLock(0) - console.log("로그아웃 되었습니다.") - } - - async function set(num?: number) { - let val = num - if(val === undefined) { - if (arg.length === 0 && that.user.getQnum() !== 0) val = that.user.getQnum() - else { - const tmp_val = parseInt(arg[0]) - if (!isNaN(tmp_val) && tmp_val >= 0) val = tmp_val - } - if (!val) { - console.log("set ") - return - } - } - const lang = that.findLang() - if (!lang) { - console.log("lang 명령어를 통해 먼저 언어를 선택해 주세요.") - return - } - const question = await getProblem(val, that.user.getCookies()) - if (question === null) { - console.log("유효하지 않은 문제 번호입니다!") - return - } - // ASSERT val is valid qnum - await that.user.setQnum(val) - console.log(`문제가 ${chalk.yellow(val + ". " + question.title)}로 설정되었습니다.`) - - let cmark = lang.commentmark ?? "" - if (!cmark) { - const result = await new Promise((resolveFunc) => { - that.r.question("현재 언어의 주석 문자를 모르겠습니다. 주석 문자를 입력해 주세요. 만약 입력을 안할경우, 문제 정보 헤더가 생성되지 않습니다. \n", (answer) => { - resolveFunc(answer) - }) - }) - cmark = result as string - setLanguageCommentMark(lang.num, cmark) - } - const username = await that.user.getUsername() - const utc = new Date().getTime() + (new Date().getTimezoneOffset() * 60 * 1000); - const KR_TIME_DIFF = 9 * 60 * 60 * 1000; - const kr_curr = new Date(utc + (KR_TIME_DIFF)); - const commentHeader = cmark ? `${cmark} -${cmark} ${question.qnum}. ${question.title} <${conf.URL}${conf.PROB}${question.qnum}> -${cmark} -${cmark} By: ${username} <${conf.URL}${conf.USER}${username}> -${cmark} Language: ${lang.name ?? ""} -${cmark} Created at: ${kr_curr.toLocaleString()} -${cmark} -${cmark} Auto-generated by BJShell -${cmark} -` - : "" - await writeMDFile(question) - const extension = lang.extension ?? "" - const filepath = `${process.cwd()}/${question.qnum}${extension}` - const langTemplate = (await readTemplateFromLocal(extension)) ?? "" - - if (await writeFile(filepath, commentHeader + langTemplate)) console.log(`${chalk.green(filepath)}에 새로운 답안 파일을 생성했습니다.`) - else console.log("파일이 존재합니다! 이전 파일을 불러옵니다.") - exec(`code ${filepath}`) - } - - function show() { - exec(`code ${conf.MDPATH}`) - if (that.firstshow) { - that.firstshow = false - console.log("VSCode에 문제 파일을 열었습니다.") - console.log("※ 만약 문제 MD 파일이 바뀌지 않는다면, ... 버튼을 클릭 후 'Refresh Preview' 버튼을 클릭해 주세요." ) - console.log("※ 만약 미리보기가 아닌 코드가 보인다면 VSCode 상에서 다음 설정을 진행해 주세요.") - console.log(` -1. "Ctrl+Shift+P" 를 누르세요 -2. "Preferences: Open User Settings (JSON) 를 클릭하세요." -3. json 파일의 마지막 } 이전에 다음 코드를 복사해서 붙여넣으세요. - , // don't forget the comma - "workbench.editorAssociations": { - "*.md": "vscode.markdown.preview.editor", - } -`) - } - } - - async function execInBJ() { - if (arg.length === 0) { - console.log("exec ") - return - } - // https://velog.io/@dev2820/nodejs%EC%9D%98-%EC%9E%90%EC%8B%9D%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4 - // https://kisaragi-hiu.com/nodejs-cmd/ - // FIXME: stdio full sync - - const command = arg.join(' ') - that.r.setPrompt('') - that.cp = spawn(command, [], { shell: true }) - await new Promise((resolveFunc) => { - that.cp!.stdout?.on("data", (x: string) => { - process.stdout.write(x.toString()); - }); - that.cp!.stderr?.on("data", (x: string) => { - process.stderr.write(x.toString()); - }); - that.cp!.on("exit", (code: number) => { - resolveFunc(code); - }); - }); - that.cp = null - } - - async function _checkInfo(): Promise<[problem, language] | null> { - const question = await getProblem(that.user.getQnum()) - if (question === null) { - console.log("유효하지 않은 문제 번호입니다!") - return null - } - const lang = that.findLang() - if (lang === undefined) { - console.log("lang 명령어를 통해 먼저 언어를 선택해 주세요.") - return null - } - return [question, lang] - } - - async function test(hideTitle?: boolean) { - const info = await _checkInfo() - if (!info) return - const [question, lang] = info - if (!hideTitle) console.log(`===== 테스트: ${question.qnum}. ${question.title} =====`) - let success: number = 0 - const extension = lang.extension ?? "" - const filepath = `${process.cwd()}/${question.qnum}${extension}` - if (!await writeMainTmp(filepath, extension)) return - - // ask compile - // const doCompile = await new Promise((resolveFunc) => { - // that.r.question("Compile? (y/n) ", (answer) => { - // resolveFunc(answer === 'y') - // }) - // }) - if (lang.compile && !lang.run.includes('Main' + extension)) { - const result = spawnSync(lang.compile.split(" ")[0], [...lang.compile.split(" ").slice(1)], { - cwd: conf.TESTPATH - }) - if (result.status !== 0) { - console.log(`${lang.compile}: ${chalk.red("컴파일 에러!")}`) - console.log(result.stderr?.toString()) - return - } - } - - const localtestcases = await parseTestCasesFromLocal(filepath) - const testcases = [...question.testcases, ...localtestcases] - for (const i in testcases) { - const prefix = parseInt(i) >= question.testcases.length ? "(커스텀) 테스트 #" : "테스트 #" - const t = testcases[i] - const expected = t.output.replace(/\r\n/g, '\n') - // default timelimit: stat.timelimit * 2 - // TODO: timelimit from language - const timelimit: number = parseInt((question.stat.timelimit.match(/\d+/) ?? ["2"])[0]) * 2 - // FIXME: javascript error - using /dev/stdin returns ENXIO: no such device or address, open '/dev/stdin' - const result = spawnSync(lang.run.split(" ")[0], [...lang.run.split(" ").slice(1)], { - input: t.input, - cwd: conf.TESTPATH, - timeout: timelimit * 1000 - }) - if (result.signal === "SIGTERM") console.log(chalk.red(`${prefix}${i} : 시간 초과! ⏰ ( > ${timelimit} sec )`)) - else if (result.status !== 0) { - console.log(chalk.red(`${prefix}${i} : 에러! ⚠`)) - console.log(result.stderr?.toString()) - } else { - const actual = String(result.stdout).replace(/\r\n/g, '\n') - if (actual.trim() == expected.trim()) { - console.log(chalk.green(`${prefix}${i} : 통과! ✅`)) - success += 1 - } - else { - console.log(chalk.red(`${prefix}${i} : 실패! ❌`)) - console.log(`예상 정답: ${expected.trim()}`); - console.log(`실행 결과: ${actual.trim()}`); - } - } - } - if (success === testcases.length) console.log(chalk.green("모든 테스트를 통과했습니다! 🎉")) - else console.log(chalk.yellow(`${success} / ${testcases.length} 개의 테스트를 통과했습니다.`)); - } - - async function testWatch() { - const info = await _checkInfo() - if (!info) return - const [question, lang] = info - console.log(`===== Test: ${question.qnum}. ${question.title} =====`) - const extension = lang.extension ?? "" - const filepath = `${process.cwd()}/${question.qnum}${extension}` - - if (!existsSync(filepath)) { - console.log("파일이 존재하지 않습니다!") - return - } - - await new Promise((resolveFunc) => { - console.log(filepath) - const monitor = chokidar.watch(filepath, { persistent: true }) - that.monitor = monitor - monitor.on("change", async function (f) { - if (f.includes(`${question.qnum}${extension}`)) { - console.log() - console.log(chalk.yellow(`파일 ${f.split("/").pop()} 가 변동되었습니다. 다시 테스트 합니다...`)) - await test(true) - } - }) - resolveFunc(0) - }) - - that.changelineModeToKeypress(async (key, data) => { - if (data.name === 'x') { - await that.revertlineModeFromKeypress() - } else if (data.name === 'b') { - console.log() - await submit() - } - }) - - await test(true) - console.log() - console.log(chalk.yellow("파일이 변동될 때까지 감시합니다...")) - console.log(chalk.yellow("만약 감시를 중단하고 싶다면, Ctrl+C를 누르거나 x를 입력하십시오.")) - } - - async function lang() { - if (arg[0] == 'list') { - const rawint = parseInt(arg[1]) - const col_num = isNaN(rawint) ? 3 : rawint - const data = [] - const langs = await getLanguages() - for (let i = 0; i < langs.length; i += col_num) { - const row = [] - for (let j = 0; j < col_num; j++) { - row.push(langs[i + j]?.name ?? "") - row.push(langs[i + j]?.extension ?? "") - row.push(langs[i + j]?.num ?? "") - } - data.push(row) - } - console.log(table(data, { drawVerticalLine: i => i % 3 === 0 })) - console.log(`원하는 언어를 사용하기 위해서 ${chalk.blueBright("lang ")}를 타이핑하세요.`) - console.log(`언어를 사용하기 전에, 자동으로 불러온 언어 설정이 유효한지 확인하세요. 그렇지 않으면, ${chalk.blueBright(conf.LANGPATH)} 파일의 \`compile\` 과 \`run\` 명령어를 수동으로 바꿔주셔야 합니다.`) - return - } - if (arg.length !== 1 || isNaN(parseInt(arg[0]))) { - console.log("lang ") - console.log("언어 목록을 보고 싶다면 lang list를 타이핑하세요.") - return - } else if (!that.findLang(parseInt(arg[0]))) { - console.log("유효하지 않은 언어 번호입니다.") - return - } - await that.user.setLang(parseInt(arg[0])) - } - - async function submit() { - const info = await _checkInfo() - if (!info) return - const [question, _] = info - that.r.pause() - try { - console.log(`===== 제출: ${question!.qnum}. ${question!.title} =====`) - const filepath = `${process.cwd()}/${that.user.getQnum()}${that.findLang()?.extension ?? ""}` - const code = await fs.readFile(filepath, 'utf-8') - const subId = await that.user.submit(code) - if (subId === -1) return - console.log(`문제를 제출했습니다!`) - for (let sec = 0; sec < 60; sec++) { - const result = await that.user.submitStatus(subId) - if (result === null) { - console.log(`제출번호 ${subId} 결과를 가져오는데 실패했습니다.`) - return - } - const result_num = parseInt(result.result) - if (isNaN(result_num)) { - console.log(`제출번호 ${subId} 결과를 파싱하는데 실패했습니다.`) - return - } - process.stdout.clearLine(0); - process.stdout.cursorTo(0); - if (result_num >= 4) { - const info = result_num === 4 ? - `${chalk.green(result.result_name)} | Time: ${result.time} ms | Memory: ${result.memory} KB` : - `${chalk.red(result.result_name)}` - console.log(info) - const username = await that.user.getUsername() - const langcode = that.findLang()?.num - console.log(`\n=> ${conf.URL}status?problem_id=${question!.qnum}&user_id=${username}&language_id=${langcode}&result_id=-1`) - break - } - process.stdout.write(`${result.result_name} (${sec} s passed)`); // end the line - await new Promise(resolve => setTimeout(resolve, 1000)) - } - } catch (e) { - if (e instanceof Error) console.log(e.message) - else console.log(e) - } finally { - that.r.resume() - } - } - - - async function probset() { - // if(arg[0] == ) - switch(arg[0]) { - case 'set': - case 's': { - if(arg.length == 1) { - console.log("probset set ") - return - } - const probsObj = await loadFromLocal("ps") - if(!probsObj) { - console.log("저장된 문제 셋이 없습니다.") - return - } - const val = parseInt(arg[1]) - if (isNaN(val) || val < 0 || val >= probsObj.probset.length) { - console.log("probset set ") - return - } - await set(probsObj.probset[val][0]) - break - } - case 'clear': - case 'c': { - await saveToLocal("ps", undefined) - console.log("문제 셋을 초기화했습니다.") - break - } - case 'next': - case 'n': { - const probsObj = await loadFromLocal("ps") - if(!probsObj) { - console.log("저장된 문제 셋이 없습니다.") - return - } - const qnum = that.user.getQnum() - const idx = probsObj.probset.findIndex((x: [number, string]) => x[0] == qnum) - if(idx == -1) { - console.log("현재 문제가 저장된 문제 셋에 없습니다.") - return - } - if(idx == probsObj.probset.length - 1) { - console.log("마지막 문제입니다.") - return - } - await set(probsObj.probset[idx + 1][0]) - break - } - case 'prev': - case 'p': { - const probsObj = await loadFromLocal("ps") - if(!probsObj) { - console.log("저장된 문제 셋이 없습니다.") - return - } - const qnum = that.user.getQnum() - const idx = probsObj.probset.findIndex((x: [number, string]) => x[0] == qnum) - if(idx == -1) { - console.log("현재 문제가 저장된 문제 셋에 없습니다.") - return - } - if(idx == 0) { - console.log("첫번째 문제입니다.") - return - } - await set(probsObj.probset[idx - 1][0]) - break - } - case 'list': - case 'l': { - const probsObj = await loadFromLocal("ps") - if(!probsObj) { - console.log("저장된 문제 셋이 없습니다.") - return - } - const data = [] - data.push(["번호", "제목"]) - console.log(`${probsObj.title}: 문제 ${probsObj.probset.length}개`) - for(const prob of probsObj.probset) { - const qnum = prob[0] - const title = prob[1] - if(qnum == that.user.getQnum()) { - data.push([chalk.green(qnum), chalk.green(title)]) - } else { - data.push([qnum, title]) - } - } - console.log(table(data)) - break - } - default: { - const urlReg = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/ - if(!urlReg.test(arg[0])) { - console.log("올바른 URL이 아닙니다.") - return - } - const probset = await getProblemSet(arg[0]) - if(probset.length == 0) { - console.log("문제가 없습니다.") - return - } - const probsetTitle = arg[1] ? arg.slice(1).join(' ') : 'My Problem Set' - const probsObj = { - title: probsetTitle, - probset - } - await saveToLocal("ps", probsObj) - console.log(`${probsetTitle}: 문제 ${probset.length}개를 저장했습니다.`) - console.log("첫번째 문제를 불러오고 있습니다...") - - await set(probset[0][0]) - break - } - } - - } - - function help(commands: { [key: string]: Command }) { - if (arg[0] == 'all') { - const data = [] - data.push(["단축어", "명령어", "설명"]) - for (const key in commands) { - const cmd = commands[key] - data.push([cmd.alias ?? "", key, cmd.desc]) - } - console.log(table(data)) - } if (arg[0] == 'testcase') { - console.log( -`${chalk.yellow("커스텀 테스트 케이스 문법 설명")} -각 언어의 주석에 태그 를 삽입합니다. 해당 태그 밖에 있는 테스트케이스는 무시됩니다. -주석의 종류(라인, 블록)는 상관없으며 , 태그의 대소문자는 구분하지 않습니다. -해당 태그 안에 있는 일반 문자들은 무시됩니다. 테스트케이스를 설명하는데 사용할 수 있습니다. -해당 태그 안에 다음과 같은 방식으로 테스트케이스 입출력 쌍을 추가할 수 있습니다. - << 와 -- 사이에 있는 문자(개행문자 포함)는 입력으로, >> 와 -- 는 출력 결과로 인식됩니다. - << 혹은 -- 다음에 오는 문자는 <<, -- 와 반드시 들여쓰기 공백 (탭) 개수를 일치시켜야 합니다. - << (input) -- (output) >> 가 하나의 테스트케이스이며, 태그에 여러개의 테스트케이스를 추가할 수 있습니다. -커스텀 테스트케이스 실행 결과에 (커스텀) 이라는 접두어가 붙습니다. - -${chalk.green(`예시) 1000.py - -""" - -1. 음수가 포함된 덧셈 - << - -1 1 - -- - 0 - >> - -2. 큰수의 덧셈 - << - 999999999999 1 - --- - 1000000000000 - >> - -""" -a, b = map(int, input().split()) -print(a + b) -`)} -` - ) - } else if (arg.length === 0) { - const data = [] - data.push(["단축어", "명령어", "설명"]) - for (const key in commands) { - const cmd = commands[key] - if(cmd.important) data.push([cmd.alias ?? "", key, cmd.desc]) - } - console.log(table(data)) - console.log("모든 명령어를 보려면 'help all' 를 타이핑하세요.") - console.log("커스텀 테스트케이스 문법을 보려면 'help testcase' 를 타이핑하세요.") - } - - } - - - const commands = { - "help": { - // desc: "Show help.", - desc: "명령어를 보여줍니다. 전체 명령어를 보려면 'help all' 을 타이핑하세요.", - func: () => help(commands), - alias: "h", - important: true - }, - "exit": { - desc: "BJ Shell을 종료합니다.", - func: () => { that.r.close() }, - alias: "x" - }, - "pwd": { - desc: "현재 디렉토리를 보여줍니다.", - func: () => { console.log(process.cwd()) } - }, - "ls": { - desc: "현재 디렉토리의 파일 목록을 보여줍니다.", - func: ls - }, - "cd": { - desc: "디렉토리를 이동합니다. (cd )", - func: cd - }, - "logout": { - desc: "BJ Shell을 로그아웃합니다.", - func: logout - }, - "set": { - desc: `VSCode에서 문제 번호를 설정하고 답안 파일을 새로 만들거나 엽니다. +export default function acquireAllCommands( + that: BJShell, + cmd: string, + arg: string[] +): { [key: string]: Command } { + const commands = { + help: { + // desc: "Show help.", + desc: "명령어를 보여줍니다. 전체 명령어를 보려면 'help all' 을 타이핑하세요.", + func: () => help(that, arg)(commands), + alias: "h", + important: true, + }, + exit: { + desc: "BJ Shell을 종료합니다.", + func: () => { + that.r.close(); + }, + alias: "x", + }, + pwd: { + desc: "현재 디렉토리를 보여줍니다.", + func: () => { + console.log(process.cwd()); + }, + }, + ls: { + desc: "현재 디렉토리의 파일 목록을 보여줍니다.", + func: ls(that, arg), + }, + cd: { + desc: "디렉토리를 이동합니다. (cd )", + func: cd(that, arg), + }, + logout: { + desc: "BJ Shell을 로그아웃합니다.", + func: logout(that, arg), + }, + set: { + desc: `VSCode에서 문제 번호를 설정하고 답안 파일을 새로 만들거나 엽니다. 또한 문제 파일을 업데이트합니다. 인수가 없으면 현재 문제 번호를 설정합니다. .bjshell/Template/Main.*에 템플릿 파일이 있으면 파일을 만들 때 템플릿을 로드합니다. 사용법: set or set`, - func: set, - alias: "s", - important: true - }, - "show": { - desc: "VSCode에서 문제 파일(problem.md)을 엽니다.", - func: show, - alias: "o", - important: true - }, - "unset": { - desc: "현재 문제 번호를 초기화합니다.", - func: async () => { await that.user.setQnum(0) } - }, - "exec": { - desc: `외부 프로세스를 실행합니다. (ex. exec python3 Main.py) (ex. e rm *.py) + func: set(that, arg), + alias: "s", + important: true, + }, + show: { + desc: "VSCode에서 문제 파일(problem.md)을 엽니다.", + func: show(that, arg), + alias: "o", + important: true, + }, + unset: { + desc: "현재 문제 번호를 초기화합니다.", + func: async () => { + await that.user.setQnum(0); + }, + }, + exec: { + desc: `외부 프로세스를 실행합니다. (ex. exec python3 Main.py) (ex. e rm *.py) SIGINT(Ctrl+C)만 처리됩니다. 파이프 등 복잡한 쉘 기능은 지원하지 않습니다. 사용법: exec `, - func: execInBJ, - alias: "e" - }, - "test": { - desc: `문제에서 제공하는 테스트케이스를 사용하여 코드를 테스트합니다. + func: exec(that, arg), + alias: "e", + }, + test: { + desc: `문제에서 제공하는 테스트케이스를 사용하여 코드를 테스트합니다. 테스트케이스는 문제 파일(problem.md)에 기록되어 있습니다. 또한, 답안 파일에 커스텀 테스트케이스를 추가할 수 있습니다. (자세한 내용은 "help testcase" 를 참고하세요.)`, - func: test, - alias: "t", - }, - "watch": { - desc: `test 명령어와 동일하지만, 파일 변동을 감지하여 자동으로 테스트를 재실행합니다. -watch 모드에서는 "b" 와 "x" 명령어를 사용할 수 있습니다. + func: test(that, arg), + alias: "t", + }, + watch: { + desc: `test 명령어와 동일하지만, 파일 변동을 감지하여 자동으로 테스트를 재실행합니다. +watch 모드를 나가지 않고 자주 사용되는 명령어를 쓸 수 있습니다. b: 즉시 제출합니다. (submit 명령어와 동일) -x: watch 모드를 종료합니다. (Ctrl + C 와 동일)`, - func: testWatch, - alias: "w", - important: true - }, - "lang": { - desc: `사용 가능한 언어를 보여줍니다. 언어를 설정하려면 lang <언어 번호> 를 타이핑하세요. +x: watch 모드를 종료합니다. (Ctrl + C 와 동일) +n: probset next 명령어와 동일합니다. +p: probset prev 명령어와 동일합니다. +l: probset list 명령어와 동일합니다.`, + func: testwatch(that, arg), + alias: "w", + important: true, + }, + lang: { + desc: `사용 가능한 언어를 보여줍니다. 언어를 설정하려면 lang <언어 번호> 를 타이핑하세요. 사용법: lang list or lang list 사용법: lang `, - func: lang, - alias: "l", - important: true - }, - "submit": { - desc: `현재 문제 번호와 언어를 사용하여 BOJ에 코드를 제출합니다.`, - func: submit, - alias: "b", - important: true - }, - "google": { - desc: `현재 문제를 구글에서 검색합니다. (링크 제공)`, - func: () => console.log(`https://www.google.com/search?q=%EB%B0%B1%EC%A4%80+${that.user.getQnum()}+${encodeURIComponent(that.findLang()?.name ?? "")}`), - alias: "g", - }, - "probset": { - desc: `URL로부터 백준 문제들을 불러옵니다. + func: lang(that, arg), + alias: "l", + important: true, + }, + submit: { + desc: `현재 문제 번호와 언어를 사용하여 BOJ에 코드를 제출합니다.`, + func: submit(that, arg), + alias: "b", + important: true, + }, + google: { + desc: `현재 문제를 구글에서 검색합니다. (링크 제공)`, + func: () => + console.log( + `https://www.google.com/search?q=%EB%B0%B1%EC%A4%80+${that.user.getQnum()}+${encodeURIComponent( + that.findLang()?.name ?? "" + )}` + ), + alias: "g", + }, + probset: { + desc: `URL로부터 백준 문제들을 불러옵니다. 사용법: -probset - url 내 존재하는 백준문제 하이퍼링크들을 파싱해 title 이름으로 문제 셋을 지정합니다. +probset - url 내 존재하는 백준문제 + 하이퍼링크들을 파싱해 title 이름으로 문제 셋을 지정합니다. probset set (or probset s) - n번째 문제를 선택합니다. probset clear (or probset c)- 저장된 문제 셋을 초기화합니다. probset next (or probset n) - 다음 문제로 넘어갑니다. probset prev (or probset p) - 이전 문제로 넘어갑니다. -probset list (or probset l) - 문제 셋 내 문제 리스트와 현재 선택된 문제를 보여줍니다. +probset list (or probset l) - 문제 셋 내 문제 리스트와 + 현재 선택된 문제를 보여줍니다. `, - func: probset, - alias: "ps", - } - } + func: probset(that, arg), + alias: "ps", + important: true, + }, + }; - return commands -} \ No newline at end of file + return commands; +} diff --git a/src/shell/commands/cd.ts b/src/shell/commands/cd.ts new file mode 100644 index 0000000..43ac184 --- /dev/null +++ b/src/shell/commands/cd.ts @@ -0,0 +1,13 @@ +import { BJShell } from "@/shell"; + +export default function cd(that: BJShell, arg: string[]) { + return () => { + try { + const path = arg[0] ?? ""; + process.chdir(path); + } catch (e) { + if (e instanceof Error) console.log(e.message); + else console.log(e); + } + }; +} diff --git a/src/shell/commands/exec.ts b/src/shell/commands/exec.ts new file mode 100644 index 0000000..c3e7108 --- /dev/null +++ b/src/shell/commands/exec.ts @@ -0,0 +1,30 @@ +import { BJShell } from "@/shell"; +import { spawn } from "child_process"; + +export default function exec(that: BJShell, arg: string[]) { + return async () => { + if (arg.length === 0) { + console.log("exec "); + return; + } + // https://velog.io/@dev2820/nodejs%EC%9D%98-%EC%9E%90%EC%8B%9D%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4 + // https://kisaragi-hiu.com/nodejs-cmd/ + // FIXME: stdio full sync + + const command = arg.join(" "); + that.r.setPrompt(""); + that.cp = spawn(command, [], { shell: true }); + await new Promise((resolveFunc) => { + that.cp!.stdout?.on("data", (x: string) => { + process.stdout.write(x.toString()); + }); + that.cp!.stderr?.on("data", (x: string) => { + process.stderr.write(x.toString()); + }); + that.cp!.on("exit", (code: number) => { + resolveFunc(code); + }); + }); + that.cp = null; + }; +} diff --git a/src/shell/commands/help.ts b/src/shell/commands/help.ts new file mode 100644 index 0000000..2e02548 --- /dev/null +++ b/src/shell/commands/help.ts @@ -0,0 +1,67 @@ +import { BJShell } from "@/shell"; +import chalk from "chalk"; +import { table } from "table"; +import { Command } from "../command"; + +export default function help(that: BJShell, arg: string[]) { + return (commands: { [key: string]: Command }) => { + if (arg[0] == "all") { + const data = []; + data.push(["단축어", "명령어", "설명"]); + for (const key in commands) { + const cmd = commands[key]; + data.push([cmd.alias ?? "", key, cmd.desc]); + } + console.log(table(data)); + } + if (arg[0] == "testcase") { + console.log( + `${chalk.yellow("커스텀 테스트 케이스 문법 설명")} +각 언어의 주석에 태그 를 삽입합니다. 해당 태그 밖에 있는 테스트케이스는 무시됩니다. +주석의 종류(라인, 블록)는 상관없으며 , 태그의 대소문자는 구분하지 않습니다. +해당 태그 안에 있는 일반 문자들은 무시됩니다. 테스트케이스를 설명하는데 사용할 수 있습니다. +해당 태그 안에 다음과 같은 방식으로 테스트케이스 입출력 쌍을 추가할 수 있습니다. + << 와 -- 사이에 있는 문자(개행문자 포함)는 입력으로, >> 와 -- 는 출력 결과로 인식됩니다. + << 혹은 -- 다음에 오는 문자는 <<, -- 와 반드시 들여쓰기 공백 (탭) 개수를 일치시켜야 합니다. + << (input) -- (output) >> 가 하나의 테스트케이스이며, 태그에 여러개의 테스트케이스를 추가할 수 있습니다. +커스텀 테스트케이스 실행 결과에 (커스텀) 이라는 접두어가 붙습니다. + +${chalk.green(`예시) 1000.py + +""" + +1. 음수가 포함된 덧셈 + << + -1 1 + -- + 0 + >> + +2. 큰수의 덧셈 + << + 999999999999 1 + --- + 1000000000000 + >> + +""" +a, b = map(int, input().split()) +print(a + b) +`)} +` + ); + } else if (arg.length === 0) { + const data = []; + data.push(["단축어", "명령어", "설명"]); + for (const key in commands) { + const cmd = commands[key]; + if (cmd.important) data.push([cmd.alias ?? "", key, cmd.desc]); + } + console.log(table(data)); + console.log("모든 명령어를 보려면 'help all' 를 타이핑하세요."); + console.log( + "커스텀 테스트케이스 문법을 보려면 'help testcase' 를 타이핑하세요." + ); + } + }; +} diff --git a/src/shell/commands/lang.ts b/src/shell/commands/lang.ts new file mode 100644 index 0000000..809e5c7 --- /dev/null +++ b/src/shell/commands/lang.ts @@ -0,0 +1,46 @@ +import { BJShell } from "@/shell"; +import { getLanguages } from "@/net/parse"; +import chalk from "chalk"; +import conf from "@/config"; +import { table } from "table"; + +export default function lang(that: BJShell, arg: string[]) { + return async () => { + if (arg[0] == "list") { + const rawint = parseInt(arg[1]); + const col_num = isNaN(rawint) ? 3 : rawint; + const data = []; + const langs = await getLanguages(); + for (let i = 0; i < langs.length; i += col_num) { + const row = []; + for (let j = 0; j < col_num; j++) { + row.push(langs[i + j]?.name ?? ""); + row.push(langs[i + j]?.extension ?? ""); + row.push(langs[i + j]?.num ?? ""); + } + data.push(row); + } + console.log(table(data, { drawVerticalLine: (i) => i % 3 === 0 })); + console.log( + `원하는 언어를 사용하기 위해서 ${chalk.blueBright( + "lang " + )}를 타이핑하세요.` + ); + console.log( + `언어를 사용하기 전에, 자동으로 불러온 언어 설정이 유효한지 확인하세요. 그렇지 않으면, ${chalk.blueBright( + conf.LANGPATH + )} 파일의 \`compile\` 과 \`run\` 명령어를 수동으로 바꿔주셔야 합니다.` + ); + return; + } + if (arg.length !== 1 || isNaN(parseInt(arg[0]))) { + console.log("lang "); + console.log("언어 목록을 보고 싶다면 lang list를 타이핑하세요."); + return; + } else if (!that.findLang(parseInt(arg[0]))) { + console.log("유효하지 않은 언어 번호입니다."); + return; + } + await that.user.setLang(parseInt(arg[0])); + }; +} diff --git a/src/shell/commands/logout.ts b/src/shell/commands/logout.ts new file mode 100644 index 0000000..7e2ab34 --- /dev/null +++ b/src/shell/commands/logout.ts @@ -0,0 +1,10 @@ +import { BJShell } from "@/shell"; + +export default function logout(that: BJShell, arg: string[]) { + return async () => { + await that.user.setToken(""); + await that.user.setAutologin(""); + that.setLoginLock(0); + console.log("로그아웃 되었습니다."); + }; +} diff --git a/src/shell/commands/ls.ts b/src/shell/commands/ls.ts new file mode 100644 index 0000000..5612e0e --- /dev/null +++ b/src/shell/commands/ls.ts @@ -0,0 +1,20 @@ +import { BJShell } from "@/shell"; +import fs from "fs/promises"; +import chalk from "chalk"; + +export default function ls(that: BJShell, arg: string[]) { + return async () => { + try { + const files = await fs.readdir(process.cwd()); + let output = ""; + for (const file of files) { + const isDir = (await fs.stat(file)).isDirectory(); + output += `${isDir ? chalk.blue(file) : file} `; + } + console.log(output); + } catch (e) { + if (e instanceof Error) console.log(e.message); + else console.log(e); + } + }; +} diff --git a/src/shell/commands/probset.ts b/src/shell/commands/probset.ts new file mode 100644 index 0000000..576579e --- /dev/null +++ b/src/shell/commands/probset.ts @@ -0,0 +1,36 @@ +import { BJShell } from "@/shell"; +import probset_set from "./probset/set"; +import probset_select from "./probset/select"; +import probset_list from "./probset/list"; +import probset_load from "./probset/load"; +import probset_clear from "./probset/clear"; + +export default function probset(that: BJShell, arg: string[]) { + return async () => { + switch (arg[0]) { + case "set": + case "s": + await probset_set(that, arg)(); + break; + case "clear": + case "c": + await probset_clear(that, arg)(); + break; + case "next": + case "n": + await probset_select(that, arg)(true); + break; + case "prev": + case "p": + await probset_select(that, arg)(false); + break; + case "list": + case "l": + await probset_list(that, arg)(); + break; + default: + await probset_load(that, arg)(); + break; + } + }; +} diff --git a/src/shell/commands/probset/clear.ts b/src/shell/commands/probset/clear.ts new file mode 100644 index 0000000..74e8315 --- /dev/null +++ b/src/shell/commands/probset/clear.ts @@ -0,0 +1,9 @@ +import { BJShell } from "@/shell"; +import { saveToLocal } from "@/storage/localstorage"; + +export default function probset_clear(that: BJShell, arg: string[]) { + return async () => { + await saveToLocal("ps", undefined); + console.log("문제 셋을 초기화했습니다."); + }; +} diff --git a/src/shell/commands/probset/list.ts b/src/shell/commands/probset/list.ts new file mode 100644 index 0000000..87e49a8 --- /dev/null +++ b/src/shell/commands/probset/list.ts @@ -0,0 +1,40 @@ +import { BJShell } from "@/shell"; +import { loadFromLocal } from "@/storage/localstorage"; +import chalk from "chalk"; +import { table } from "table"; + +export default function probset_list(that: BJShell, arg: string[]) { + return async () => { + const probsObj = await loadFromLocal("ps"); + if (!probsObj) { + console.log("저장된 문제 셋이 없습니다."); + return; + } + console.log(chalk.yellow(`문제집 이름: ${probsObj.title}`)); + console.log(chalk.blue(`링크: ${probsObj?.url}`)); + const data = []; + const col = 3; + const row = Math.ceil(probsObj.probset.length / col); + for (let i = 0; i < row; i++) { + const rowdata = []; + for (let j = 0; j < col; j++) { + const idx = i * col + j; + if (idx >= probsObj.probset.length) { + rowdata.push(...["", "", ""]); + continue; + } + const qnum = probsObj.probset[idx][0]; + const title = probsObj.probset[idx][1]; + if (qnum == that.user.getQnum()) { + rowdata.push( + ...[chalk.green(idx), chalk.green(qnum), chalk.green(title)] + ); + } else { + rowdata.push(...[idx, qnum, title]); + } + } + data.push(rowdata); + } + console.log(table(data, { drawVerticalLine: (i) => i % 3 === 0 })); + }; +} diff --git a/src/shell/commands/probset/load.ts b/src/shell/commands/probset/load.ts new file mode 100644 index 0000000..04576e4 --- /dev/null +++ b/src/shell/commands/probset/load.ts @@ -0,0 +1,31 @@ +import { BJShell } from "@/shell"; +import { saveToLocal } from "@/storage/localstorage"; +import set from "../set"; +import { getProblemSet } from "@/net/parse"; + +export default function probset_load(that: BJShell, arg: string[]) { + return async () => { + const urlReg = + /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/; + if (!urlReg.test(arg[0])) { + console.log("올바른 URL이 아닙니다."); + return; + } + const probset = await getProblemSet(arg[0]); + if (probset.length == 0) { + console.log("문제가 없습니다."); + return; + } + const probsetTitle = arg[1] ? arg.slice(1).join(" ") : "My Problem Set"; + const probsObj = { + title: probsetTitle, + url: arg[0], + probset, + }; + await saveToLocal("ps", probsObj); + console.log(`${probsetTitle}: 문제 ${probset.length}개를 저장했습니다.`); + console.log("첫번째 문제를 불러오고 있습니다..."); + + await set(that, arg)(probset[0][0]); + }; +} diff --git a/src/shell/commands/probset/select.ts b/src/shell/commands/probset/select.ts new file mode 100644 index 0000000..a395948 --- /dev/null +++ b/src/shell/commands/probset/select.ts @@ -0,0 +1,30 @@ +import { BJShell } from "@/shell"; +import { loadFromLocal } from "@/storage/localstorage"; +import set from "../set"; + +export default function probset_select(that: BJShell, arg: string[]) { + return async (next: boolean) => { + const probsObj = await loadFromLocal("ps"); + if (!probsObj) { + console.log("저장된 문제 셋이 없습니다."); + return; + } + const qnum = that.user.getQnum(); + const idx = probsObj.probset.findIndex( + (x: [number, string]) => x[0] == qnum + ); + if (idx == -1) { + console.log("현재 문제가 저장된 문제 셋에 없습니다."); + return; + } + if (idx == 0 && !next) { + console.log("첫 번째 문제입니다."); + return; + } + if (idx == probsObj.probset.length - 1 && next) { + console.log("마지막 문제입니다."); + return; + } + await set(that, arg)(probsObj.probset[next ? idx + 1 : idx - 1][0]); + }; +} diff --git a/src/shell/commands/probset/set.ts b/src/shell/commands/probset/set.ts new file mode 100644 index 0000000..b3c989f --- /dev/null +++ b/src/shell/commands/probset/set.ts @@ -0,0 +1,23 @@ +import { BJShell } from "@/shell"; +import { loadFromLocal } from "@/storage/localstorage"; +import set from "../set"; + +export default function probset_set(that: BJShell, arg: string[]) { + return async () => { + if (arg.length == 1) { + console.log("probset set "); + return; + } + const probsObj = await loadFromLocal("ps"); + if (!probsObj) { + console.log("저장된 문제 셋이 없습니다."); + return; + } + const val = parseInt(arg[1]); + if (isNaN(val) || val < 0 || val >= probsObj.probset.length) { + console.log("probset set "); + return; + } + await set(that, arg)(probsObj.probset[val][0]); + }; +} diff --git a/src/shell/commands/set.ts b/src/shell/commands/set.ts new file mode 100644 index 0000000..10f2c05 --- /dev/null +++ b/src/shell/commands/set.ts @@ -0,0 +1,88 @@ +import { BJShell } from "@/shell"; +import chalk from "chalk"; +import conf from "@/config"; +import { exec } from "child_process"; +import { getProblem, setLanguageCommentMark } from "@/net/parse"; +import { readTemplateFromLocal } from "@/storage/filereader"; +import { writeFile, writeMDFile } from "@/storage/filewriter"; +import { generateFilePath, getFilePath } from "../utils"; +import { get } from "@/net/fetch"; + +export default function set(that: BJShell, arg: string[]) { + return async (num?: number) => { + let val = num; + if (val === undefined) { + if (arg.length === 0 && that.user.getQnum() !== 0) + val = that.user.getQnum(); + else { + const tmp_val = parseInt(arg[0]); + if (!isNaN(tmp_val) && tmp_val >= 0) val = tmp_val; + } + if (!val) { + console.log("set "); + return; + } + } + const lang = that.findLang(); + if (!lang) { + console.log("lang 명령어를 통해 먼저 언어를 선택해 주세요."); + return; + } + const question = await getProblem(val, that.user.getCookies()); + if (question === null) { + console.log("유효하지 않은 문제 번호입니다!"); + return; + } + // ASSERT val is valid qnum + await that.user.setQnum(val); + console.log( + `문제가 ${chalk.yellow(val + ". " + question.title)}로 설정되었습니다.` + ); + + let cmark = lang.commentmark ?? ""; + if (!cmark) { + const result = await new Promise((resolveFunc) => { + that.r.question( + "현재 언어의 주석 문자를 모르겠습니다. 주석 문자를 입력해 주세요. 만약 입력을 안할경우, 문제 정보 헤더가 생성되지 않습니다. \n", + (answer) => { + resolveFunc(answer); + } + ); + }); + cmark = result as string; + setLanguageCommentMark(lang.num, cmark); + } + const username = await that.user.getUsername(); + const utc = + new Date().getTime() + new Date().getTimezoneOffset() * 60 * 1000; + const KR_TIME_DIFF = 9 * 60 * 60 * 1000; + const kr_curr = new Date(utc + KR_TIME_DIFF); + const commentHeader = cmark + ? `${cmark} +${cmark} ${question.qnum}. ${question.title} <${conf.URL}${conf.PROB}${ + question.qnum + }> +${cmark} +${cmark} By: ${username} <${conf.URL}${conf.USER}${username}> +${cmark} Language: ${lang.name ?? ""} +${cmark} Created at: ${kr_curr.toLocaleString()} +${cmark} +${cmark} Auto-generated by BJShell +${cmark} +` + : ""; + await writeMDFile(question); + const extension = lang.extension ?? ""; + let filepath = await getFilePath(that, true); + if(!filepath) + filepath = await generateFilePath(that); + const langTemplate = (await readTemplateFromLocal(extension)) ?? ""; + + if (await writeFile(filepath, commentHeader + langTemplate)) + console.log( + `${chalk.green(filepath)}에 새로운 답안 파일을 생성했습니다.` + ); + else console.log("파일이 존재합니다! 이전 파일을 불러옵니다."); + exec(`code ${filepath.replace(/ /g, "\\ ")}`); + }; +} diff --git a/src/shell/commands/show.ts b/src/shell/commands/show.ts new file mode 100644 index 0000000..d6ddaca --- /dev/null +++ b/src/shell/commands/show.ts @@ -0,0 +1,28 @@ +import { BJShell } from "@/shell"; +import conf from "@/config"; +import { exec } from "child_process"; + +export default function show(that: BJShell, arg: string[]) { + return () => { + exec(`code ${conf.MDPATH}`); + if (that.firstshow) { + that.firstshow = false; + console.log("VSCode에 문제 파일을 열었습니다."); + console.log( + "※ 만약 문제 MD 파일이 바뀌지 않는다면, ... 버튼을 클릭 후 'Refresh Preview' 버튼을 클릭해 주세요." + ); + console.log( + "※ 만약 미리보기가 아닌 코드가 보인다면 VSCode 상에서 다음 설정을 진행해 주세요." + ); + console.log(` +1. "Ctrl+Shift+P" 를 누르세요 +2. "Preferences: Open User Settings (JSON) 를 클릭하세요." +3. json 파일의 마지막 } 이전에 다음 코드를 복사해서 붙여넣으세요. + , // don't forget the comma + "workbench.editorAssociations": { + "*.md": "vscode.markdown.preview.editor", + } +`); + } + }; +} diff --git a/src/shell/commands/submit.ts b/src/shell/commands/submit.ts new file mode 100644 index 0000000..e270283 --- /dev/null +++ b/src/shell/commands/submit.ts @@ -0,0 +1,61 @@ +import chalk from "chalk"; +import { BJShell } from "@/shell"; +import { checkInfo, getFilePath } from "../utils"; +import fs from "fs/promises"; +import conf from "@/config"; + +export default function submit(that: BJShell, arg: string[]) { + return async () => { + const info = await checkInfo(that); + if (!info) return; + const [question, _] = info; + that.r.pause(); + try { + console.log(`===== 제출: ${question!.qnum}. ${question!.title} =====`); + const filepath = await getFilePath(that); + if(!filepath) return; + const code = await fs.readFile(filepath, "utf-8"); + const subId = await that.user.submit(code); + if (subId === -1) return; + console.log(`문제를 제출했습니다!`); + for (let sec = 0; sec < 60; sec++) { + const result = await that.user.submitStatus(subId); + if (result === null) { + console.log(`제출번호 ${subId} 결과를 가져오는데 실패했습니다.`); + return; + } + const result_num = parseInt(result.result); + if (isNaN(result_num)) { + console.log(`제출번호 ${subId} 결과를 파싱하는데 실패했습니다.`); + return; + } + process.stdout.clearLine(0); + process.stdout.cursorTo(0); + if (result_num >= 4) { + const info = + result_num === 4 + ? `${chalk.green(result.result_name)} | Time: ${ + result.time + } ms | Memory: ${result.memory} KB` + : `${chalk.red(result.result_name)}`; + console.log(info); + const username = await that.user.getUsername(); + const langcode = that.findLang()?.num; + console.log( + `=> ${conf.URL}status?problem_id=${ + question!.qnum + }&user_id=${username}&language_id=${langcode}&result_id=-1` + ); + break; + } + process.stdout.write(`${result.result_name} (${sec} s passed)`); // end the line + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } catch (e) { + if (e instanceof Error) console.log(e.message); + else console.log(e); + } finally { + that.r.resume(); + } + }; +} diff --git a/src/shell/commands/template.ts b/src/shell/commands/template.ts new file mode 100644 index 0000000..f718f77 --- /dev/null +++ b/src/shell/commands/template.ts @@ -0,0 +1,5 @@ +import { BJShell } from "@/shell"; + +export default function template(that: BJShell, arg: string[]) { + return async () => {}; +} diff --git a/src/shell/commands/test.ts b/src/shell/commands/test.ts new file mode 100644 index 0000000..c33914b --- /dev/null +++ b/src/shell/commands/test.ts @@ -0,0 +1,99 @@ +import { BJShell } from "@/shell"; +import { parseTestCasesFromLocal } from "@/storage/filereader"; +import { writeMainTmp } from "@/storage/filewriter"; +import { spawnSync } from "child_process"; +import conf from "@/config"; +import chalk from "chalk"; +import { checkInfo, getFilePath } from "../utils"; + +export default function test(that: BJShell, arg: string[]) { + return async (hideTitle?: boolean) => { + const info = await checkInfo(that); + if (!info) return; + const [question, lang] = info; + if (!hideTitle) + console.log(`===== 테스트: ${question.qnum}. ${question.title} =====`); + let success: number = 0; + const extension = lang.extension ?? ""; + const filepath = await getFilePath(that); + if(!filepath) return; + if (!(await writeMainTmp(filepath, extension))) return; + + // ask compile + // const doCompile = await new Promise((resolveFunc) => { + // that.r.question("Compile? (y/n) ", (answer) => { + // resolveFunc(answer === 'y') + // }) + // }) + if (lang.compile && !lang.run.includes("Main" + extension)) { + const result = spawnSync( + lang.compile.split(" ")[0], + [...lang.compile.split(" ").slice(1)], + { + cwd: conf.TESTPATH, + } + ); + if (result.status !== 0) { + console.log(`${lang.compile}: ${chalk.red("컴파일 에러!")}`); + console.log(result.stderr?.toString()); + return; + } + } + + const localtestcases = await parseTestCasesFromLocal(filepath); + const testcases = [...question.testcases, ...localtestcases]; + for (const i in testcases) { + const prefix = + parseInt(i) >= question.testcases.length + ? "(커스텀) 테스트 #" + : "테스트 #"; + const t = testcases[i]; + const expected = t.output.replace(/\r\n/g, "\n"); + const timeCondMatch = lang.timelimit.match(/×(\d+)(\+(\d+))?/); + const timeCondMul = timeCondMatch ? parseInt(timeCondMatch[1]) : 1; + const timeCondAdd = timeCondMatch ? parseInt(timeCondMatch[3]) : 0; + + const rawTimelimit: number = + parseFloat((question.stat.timelimit.match(/\d+(\.\d+)?/) ?? ["2"])[0]); + const timelimit = timeCondMul * rawTimelimit + timeCondAdd; + + // FIXME: javascript error - using /dev/stdin returns ENXIO: no such device or address, open '/dev/stdin' + const result = spawnSync( + lang.run.split(" ")[0], + [...lang.run.split(" ").slice(1)], + { + input: t.input, + cwd: conf.TESTPATH, + timeout: timelimit * 1000, + } + ); + if (result.signal === "SIGTERM") + console.log( + chalk.red(`${prefix}${i} : 시간 초과! ⏰ ( > ${timelimit} sec )`) + ); + else if (result.status !== 0) { + const sigsuffix = result.signal ? ` (${result.signal})` : ""; + console.log(chalk.red(`${prefix}${i} : 런타임 에러!${sigsuffix}`)); + console.log(result.stderr?.toString()); + } else { + const actual = String(result.stdout).replace(/\r\n/g, "\n"); + if (actual.trim() == expected.trim()) { + console.log(chalk.green(`${prefix}${i} : 통과! ✅`)); + success += 1; + } else { + console.log(chalk.red(`${prefix}${i} : 실패! ❌`)); + console.log(`예상 정답: ${expected.trim()}`); + console.log(`실행 결과: ${actual.trim()}`); + } + } + } + if (success === testcases.length) + console.log(chalk.green("모든 테스트를 통과했습니다! 🎉")); + else + console.log( + chalk.yellow( + `${success} / ${testcases.length} 개의 테스트를 통과했습니다.` + ) + ); + }; +} diff --git a/src/shell/commands/testwatch.ts b/src/shell/commands/testwatch.ts new file mode 100644 index 0000000..743ef23 --- /dev/null +++ b/src/shell/commands/testwatch.ts @@ -0,0 +1,75 @@ +import { BJShell } from "@/shell"; +import test from "./test"; +import { checkInfo, getFilePath } from "../utils"; +import { existsSync } from "fs"; +import chokidar from "chokidar"; +import chalk from "chalk"; +import submit from "./submit"; +import probset_select from "./probset/select"; +import probset_list from "./probset/list"; + +export default function testwatch(that: BJShell, arg: string[]) { + return async () => { + const info = await checkInfo(that); + if (!info) return; + const [question, lang] = info; + console.log(`===== Test: ${question.qnum}. ${question.title} =====`); + const filepath = await getFilePath(that); + if (!filepath) return; + await new Promise((resolveFunc) => { + console.log(filepath); + const monitor = chokidar.watch(filepath, { persistent: true }); + that.monitor = monitor; + let test_lock = false; + monitor.on("change", async function (f) { + if (f === filepath) { + if (test_lock) return; + test_lock = true; + await new Promise((resolve) => setTimeout(resolve, 200)); + console.log(); + console.log( + chalk.yellow( + `파일 ${f + .split("/") + .pop()} 가 변동되었습니다. 다시 테스트 합니다...` + ) + ); + await test(that, arg)(true); + test_lock = false; + } + }); + resolveFunc(0); + }); + + that.changelineModeToKeypress(async (key, data) => { + if (data.name === "x") { + await that.revertlineModeFromKeypress(); + } else if (data.name === "b") { + console.log(); + await submit(that, arg)(); + } else if (data.name === "n") { + console.log(); + await that.revertlineModeFromKeypress(); + await probset_select(that, arg)(true); + await testwatch(that, arg)(); + } else if (data.name === "p") { + console.log(); + await that.revertlineModeFromKeypress(); + await probset_select(that, arg)(false); + await testwatch(that, arg)(); + } else if (data.name === "l") { + console.log(); + await probset_list(that, arg)(); + } + }); + + await test(that, arg)(true); + console.log(); + console.log(chalk.yellow("파일이 변동될 때까지 감시합니다...")); + console.log( + chalk.yellow( + "만약 감시를 중단하고 싶다면, Ctrl+C를 누르거나 x를 입력하십시오." + ) + ); + }; +} diff --git a/src/shell/index.ts b/src/shell/index.ts index e7a1367..511d783 100644 --- a/src/shell/index.ts +++ b/src/shell/index.ts @@ -1,162 +1,174 @@ -import readline from 'readline' -import chalk from 'chalk' -import os from 'os' -import { User } from '@/net/user' -import { ChildProcessWithoutNullStreams } from 'child_process' -import kill from 'tree-kill' -import { getLanguage, getLanguages, language } from '@/net/parse' -import acquireAllCommands from './command' -import { FSWatcher } from 'chokidar' - -//type LoginLock = NOT_LOGGED_IN | AUTO_LOGIN_TOKEN | LOGGED_IN -type LoginLock = 0 | 1 | 2 +import readline from "readline"; +import chalk from "chalk"; +import os from "os"; +import { User } from "@/net/user"; +import { ChildProcessWithoutNullStreams } from "child_process"; +import kill from "tree-kill"; +import { getLanguage, getLanguages, language } from "@/net/parse"; +import acquireAllCommands from "./command"; +import { FSWatcher } from "chokidar"; + +//type LoginLock = NOT_LOGGED_IN | AUTO_LOGIN_TOKEN | LOGGED_IN +type LoginLock = 0 | 1 | 2; export class BJShell { - r = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) - user = new User("") - #loginLock: LoginLock = 2 - #prevCommand = "" - cp: ChildProcessWithoutNullStreams | null = null - monitor: FSWatcher | null = null - keyeventListener: ((key: string, data: any) => Promise) | null = null - - firstshow = true - - findLang(num?: number): language | undefined { - return getLanguage(num ?? this.user.getLang()) + r = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + user = new User(""); + #loginLock: LoginLock = 2; + #prevCommand = ""; + cp: ChildProcessWithoutNullStreams | null = null; + monitor: FSWatcher | null = null; + keyeventListener: ((key: string, data: any) => Promise) | null = null; + + firstshow = true; + + findLang(num?: number): language | undefined { + return getLanguage(num ?? this.user.getLang()); + } + + setLoginLock(lock: LoginLock) { + this.#loginLock = lock; + } + + async setPrompt(cmd?: string) { + if (this.#loginLock === 0) this.r.setPrompt("Enter login token: "); + else if (this.#loginLock === 1) + this.r.setPrompt("(Optional) Enter autologin token: "); + else if (this.monitor) + this.r.setPrompt( + "" + ); // monitor block stdin, so no prompt (cp's blank prompt set in exec command) + else { + const rawdir = chalk.green(process.cwd()); + const dir = rawdir.replace(os.homedir(), "~"); + const curLangName = this.findLang()?.name ?? ""; + const prefix = + `👤 ${chalk.blueBright(await this.user.getUsername())}` + + (this.user.getQnum() + ? ` | 🚩 ${chalk.yellow(this.user.getQnum())}` + : "") + + (this.user.getLang() !== -1 + ? ` | 🌐 ${chalk.yellow(curLangName)}` + : ""); + this.r.setPrompt(`(${prefix}) ${dir} BJ> `); } - - setLoginLock(lock: LoginLock) { - this.#loginLock = lock - } - - async setPrompt(cmd?: string) { - if (this.#loginLock === 0) this.r.setPrompt('Enter login token: ') - else if (this.#loginLock === 1) this.r.setPrompt('(Optional) Enter autologin token: ') - else if (this.monitor) this.r.setPrompt('') // monitor block stdin, so no prompt (cp's blank prompt set in exec command) - else { - const rawdir = chalk.green(process.cwd()); - const dir = rawdir.replace(os.homedir(), "~") - const curLangName = this.findLang()?.name ?? "" - const prefix = `👤 ${chalk.blueBright(await this.user.getUsername())}` - + (this.user.getQnum() ? ` | 🚩 ${chalk.yellow(this.user.getQnum())}` : "") - + (this.user.getLang() !== -1 ? ` | 🌐 ${chalk.yellow(curLangName)}` : "") - this.r.setPrompt(`(${prefix}) ${dir} BJ> `) - } - if (cmd !== 'exec') this.r.prompt() - } - - async #loginGuard() { - // Check curruent token exists or vaild - if (await this.user.checkLogin() === 200) return true - console.log(`${chalk.red("로그인이 필요합니다.")}`) - console.log(`만약 토큰을 어떻게 찾는지 모르겠다면, 여기를 참고하세요: https://github.com/TriangleYJ/Beakjoon-VSC`) - this.setLoginLock(0) - return false - } - - async #loginGuardOnLine(line: string) { - if (this.#loginLock === 2) return false - if (this.#loginLock === 0) { - await this.user.setToken(line) - if (await this.user.checkLogin() === 200) this.setLoginLock(1) - else console.log("유효하지 않은 로그인 토큰입니다.") - await this.setPrompt() - } else if (this.#loginLock === 1) { - await this.user.setAutologin(line) - this.#loginLock = 2 - console.log(chalk.green("로그인 성공!")) - console.log() - await getLanguages(true) - await this.setPrompt() - } - return true - } - - lineListener = async (line: string) => { - if (this.cp) { // prior handling 1: child process stdin - this.cp.stdin.write(line + '\n'); - return - } - if (await this.#loginGuardOnLine(line)) return // prior handling 3: login guard - - line = line.trim() - if (line === '.') line = this.#prevCommand - - const argv = line.split(' ') - let cmd = argv[0] - const arg = argv.slice(1) - const commands = acquireAllCommands(this, cmd, arg) - const commAlias = Object.values(commands).find(x => x.alias === cmd) - if (commAlias) await commAlias.func() - else if (commands[cmd]) await commands[cmd].func() - else if (cmd !== "") console.log("Unknown Command") - - await this.setPrompt() - this.#prevCommand = line - return + if (cmd !== "exec") this.r.prompt(); + } + + async #loginGuard() { + // Check curruent token exists or vaild + if ((await this.user.checkLogin()) === 200) return true; + console.log(`${chalk.red("로그인이 필요합니다.")}`); + console.log( + `만약 토큰을 어떻게 찾는지 모르겠다면, 여기를 참고하세요: https://github.com/TriangleYJ/Beakjoon-VSC` + ); + this.setLoginLock(0); + return false; + } + + async #loginGuardOnLine(line: string) { + if (this.#loginLock === 2) return false; + if (this.#loginLock === 0) { + await this.user.setToken(line); + if ((await this.user.checkLogin()) === 200) this.setLoginLock(1); + else console.log("유효하지 않은 로그인 토큰입니다."); + await this.setPrompt(); + } else if (this.#loginLock === 1) { + await this.user.setAutologin(line); + this.#loginLock = 2; + console.log(chalk.green("로그인 성공!")); + console.log(); + await getLanguages(true); + await this.setPrompt(); } - - async changelineModeToKeypress(keypressListener: (key: string, data: any) => Promise) { - this.r.removeListener('line', this.lineListener) - readline.emitKeypressEvents(process.stdin); - process.stdin.setRawMode(true) - - this.keyeventListener = keypressListener - process.stdin.on('keypress', keypressListener) + return true; + } + + lineListener = async (line: string) => { + if (this.cp) { + // prior handling 1: child process stdin + this.cp.stdin.write(line + "\n"); + return; } - - async revertlineModeFromKeypress() { - this.monitor?.close() - this.monitor = null - if (this.keyeventListener) { - process.stdin.removeListener('keypress', this.keyeventListener) - this.keyeventListener = null - } - this.r.write(null, { ctrl: true, name: 'u' }); - await this.setPrompt() - this.r.on('line', this.lineListener) + if (await this.#loginGuardOnLine(line)) return; // prior handling 3: login guard + + line = line.trim(); + if (line === ".") line = this.#prevCommand; + + const argv = line.split(" "); + let cmd = argv[0]; + const arg = argv.slice(1); + const commands = acquireAllCommands(this, cmd, arg); + const commAlias = Object.values(commands).find((x) => x.alias === cmd); + if (commAlias) await commAlias.func(); + else if (commands[cmd]) await commands[cmd].func(); + else if (cmd !== "") console.log("Unknown Command"); + + await this.setPrompt(); + this.#prevCommand = line; + return; + }; + + async changelineModeToKeypress( + keypressListener: (key: string, data: any) => Promise + ) { + this.r.removeListener("line", this.lineListener); + readline.emitKeypressEvents(process.stdin); + process.stdin.setRawMode(true); + + this.keyeventListener = keypressListener; + process.stdin.on("keypress", keypressListener); + } + + async revertlineModeFromKeypress() { + this.monitor?.close(); + this.monitor = null; + if (this.keyeventListener) { + process.stdin.removeListener("keypress", this.keyeventListener); + this.keyeventListener = null; } - - #initOn() { - this.r.on('line', this.lineListener) - this.r.on('close', function () { - process.exit() - }) - - // Handle Ctrl+C (SIGINT) to send it to the child process - this.r.on('SIGINT', async () => { - if (this.monitor) { - await this.revertlineModeFromKeypress() - } - else if (this.cp === null) { - console.log() - this.r.write(null, { ctrl: true, name: 'u' }); - await this.setPrompt() - } - else kill(this.cp.pid ?? 0, 'SIGINT', (err: any) => { - if (err) { - if (err instanceof Error) console.log(err.message) - else console.log(err) - } - }) + this.r.write(null, { ctrl: true, name: "u" }); + await this.setPrompt(); + this.r.on("line", this.lineListener); + } + + #initOn() { + this.r.on("line", this.lineListener); + this.r.on("close", function () { + process.exit(); + }); + + // Handle Ctrl+C (SIGINT) to send it to the child process + this.r.on("SIGINT", async () => { + if (this.monitor) { + await this.revertlineModeFromKeypress(); + } else if (this.cp === null) { + console.log(); + this.r.write(null, { ctrl: true, name: "u" }); + await this.setPrompt(); + } else + kill(this.cp.pid ?? 0, "SIGINT", (err: any) => { + if (err) { + if (err instanceof Error) console.log(err.message); + else console.log(err); + } }); + }); + } - } - - async init() { - this.#initOn() + async init() { + this.#initOn(); - console.log(`${chalk.yellow("BaekJoon Shell")} 에 오신 것을 환영합니다!`) - console.log(`${chalk.blue("help")}를 입력하여 명령어를 확인하세요.`) - console.log() + console.log(`${chalk.yellow("BaekJoon Shell")} 에 오신 것을 환영합니다!`); + console.log(`${chalk.blue("help")}를 입력하여 명령어를 확인하세요.`); + console.log(); - // Load config - await this.user.loadProperties() - if (await this.#loginGuard()) await getLanguages() - await this.setPrompt() - } + // Load config + await this.user.loadProperties(); + if (await this.#loginGuard()) await getLanguages(); + await this.setPrompt(); + } } diff --git a/src/shell/utils.ts b/src/shell/utils.ts new file mode 100644 index 0000000..0898536 --- /dev/null +++ b/src/shell/utils.ts @@ -0,0 +1,46 @@ +import { problem, language, getProblem } from "@/net/parse"; +import { BJShell } from "@/shell"; +import { existsSync } from "fs"; + +export async function checkInfo( + that: BJShell +): Promise<[problem, language] | null> { + const question = await getProblem(that.user.getQnum()); + if (question === null) { + console.log("유효하지 않은 문제 번호입니다!"); + return null; + } + const lang = that.findLang(); + if (lang === undefined) { + console.log("lang 명령어를 통해 먼저 언어를 선택해 주세요."); + return null; + } + return [question, lang]; +} + +export async function generateFilePath( + that: BJShell, + numOnly?: boolean +): Promise { + const info = await checkInfo(that); + if (!info) return ""; + const [question, lang] = info; + const extension = lang.extension ?? ""; + const titleEscaped = question.title.replace(/[/\\?%*:|"<>]/g, ""); + let filepath = numOnly + ? `${process.cwd()}/${question.qnum}${extension}` + : `${process.cwd()}/${question.qnum} - ${titleEscaped}${extension}`; + return filepath; +} + +// Assure that the file exists +export async function getFilePath(that: BJShell, silent?: boolean): Promise { + // if generateFilePath(that) exists in file, return it + // if not, return generateFilePath(that, true) + const filepath = await generateFilePath(that); + if (existsSync(filepath)) return filepath; + const filepath2 = await generateFilePath(that, true); + if (existsSync(filepath2)) return filepath2; + if(!silent) console.log("파일이 존재하지 않습니다!"); + return ""; +}