diff --git a/calva/connector.ts b/calva/connector.ts index f3da49809..5e3e38de6 100644 --- a/calva/connector.ts +++ b/calva/connector.ts @@ -6,80 +6,14 @@ import * as state from './state'; import * as util from './utilities'; import * as open from 'open'; import status from './status'; +import * as projectTypes from './nrepl/project-types'; const { parseEdn } = require('../cljs-out/cljs-lib'); import { NReplClient, NReplSession } from "./nrepl"; -import { reconnectReplWindow, openReplWindow } from './repl-window'; +import { reconnectReplWindow, openReplWindow, sendTextToREPLWindow } from './repl-window'; +import { CljsTypeConfig, ReplConnectSequence, getDefaultCljsType, CljsTypes, askForConnectSequence } from './nrepl/connectSequence'; -const PROJECT_DIR_KEY = "connect.projectDir"; -const PROJECT_WS_FOLDER_KEY = "connect.projecWsFolder"; - -export function getProjectRoot(): string { - return state.deref().get(PROJECT_DIR_KEY); -} - -export function getProjectWsFolder(): vscode.WorkspaceFolder { - return state.deref().get(PROJECT_WS_FOLDER_KEY); -} - -/** - * Figures out, and stores, the current clojure project root - * Also stores the WorkSpace folder for the project to be used - * when executing the Task and get proper vscode reporting. - * - * 1. If there is no file open. Stop and complain. - * 2. If there is a file open, use it to determine the project root - * by looking for project files from the file's directory and up to - * the window root (for plain folder windows) or the file's - * workspace folder root (for workspaces) to find the project root. - * - * If there is no project file found, then store either of these - * 1. the window root for plain folders - * 2. first workspace root for workspaces. - * (This situation will be detected later by the connect process.) - */ -export async function initProjectDir(): Promise { - const projectFileNames: string[] = ["project.clj", "shadow-cljs.edn", "deps.edn"], - doc = util.getDocument({}), - workspaceFolder = doc ? vscode.workspace.getWorkspaceFolder(doc.uri) : null; - if (!workspaceFolder) { - vscode.window.showErrorMessage("There is no document opened in the workspace. Aborting. Please open a file in your Clojure project and try again."); - state.analytics().logEvent("REPL", "JackinOrConnectInterrupted", "NoCurrentDocument").send(); - throw "There is no document opened in the workspace. Aborting."; - } else { - state.cursor.set(PROJECT_WS_FOLDER_KEY, workspaceFolder); - let rootPath: string = path.resolve(workspaceFolder.uri.fsPath); - let d = path.dirname(doc.uri.fsPath); - let prev = null; - while (d != prev) { - for (let projectFile in projectFileNames) { - const p = path.resolve(d, projectFileNames[projectFile]); - if (fs.existsSync(p)) { - rootPath = d; - break; - } - } - if (d == rootPath) { - break; - } - prev = d; - d = path.resolve(d, ".."); - } - state.cursor.set(PROJECT_DIR_KEY, rootPath); - } -} - -export type ProjectType = { - name: string; - cljsTypes: string[]; - cmd: string; - winCmd: string; - commandLine: (includeCljs: boolean) => any; - useWhenExists: string; - nReplPortFile: () => string; -}; - -async function connectToHost(hostname, port, cljsTypeName: string, replTypes: ReplType[]) { +async function connectToHost(hostname, port, connectSequence: ReplConnectSequence) { state.analytics().logEvent("REPL", "Connecting").send(); let chan = state.outputChannel(); @@ -115,17 +49,29 @@ async function connectToHost(hostname, port, cljsTypeName: string, replTypes: Re state.cursor.set('cljc', cljSession) status.update(); + if (connectSequence.afterCLJReplJackInCode) { + state.outputChannel().appendLine("Evaluating `afterCLJReplJackInCode` in CLJ REPL Window"); + await sendTextToREPLWindow(connectSequence.afterCLJReplJackInCode, null, false); + } + //cljsSession = nClient.session; //terminal.createREPLTerminal('clj', null, chan); let cljsSession = null, - shadowBuild = null; + cljsBuild = null; try { - [cljsSession, shadowBuild] = cljsTypeName != "" ? await makeCljsSessionClone(cljSession, cljsTypeName, replTypes) : [null, null]; + if (connectSequence.cljsType != undefined) { + const isBuiltinType: boolean = typeof connectSequence.cljsType == "string"; + let cljsType: CljsTypeConfig = isBuiltinType ? getDefaultCljsType(connectSequence.cljsType as string) : connectSequence.cljsType as CljsTypeConfig; + translatedReplType = createCLJSReplType(cljsType, projectTypes.getCljsTypeName(connectSequence), connectSequence); + + [cljsSession, cljsBuild] = await makeCljsSessionClone(cljSession, translatedReplType, connectSequence.name); + state.analytics().logEvent("REPL", "ConnectCljsRepl", isBuiltinType ? connectSequence.cljsType as string: "Custom").send(); + } } catch (e) { chan.appendLine("Error while connecting cljs REPL: " + e); } if (cljsSession) - await setUpCljsRepl(cljsSession, chan, shadowBuild); + await setUpCljsRepl(cljsSession, chan, cljsBuild); chan.appendLine('cljc files will use the clj REPL.' + (cljsSession ? ' (You can toggle this at will.)' : '')); //evaluate.loadFile(); status.update(); @@ -141,52 +87,32 @@ async function connectToHost(hostname, port, cljsTypeName: string, replTypes: Re return true; } -async function setUpCljsRepl(cljsSession, chan, shadowBuild) { +async function setUpCljsRepl(cljsSession, chan, build) { state.cursor.set("cljs", cljsSession); - chan.appendLine("Connected session: cljs"); + chan.appendLine("Connected session: cljs" + (build ? ", repl: " + build : "")); await openReplWindow("cljs", true); await reconnectReplWindow("cljs"); - //terminal.createREPLTerminal('cljs', shadowBuild, chan); status.update(); } -export function shadowConfigFile() { - return getProjectRoot() + '/shadow-cljs.edn'; -} - -export function shadowBuilds() { - let parsed = parseEdn(fs.readFileSync(shadowConfigFile(), 'utf8').toString()), - builds = _.map(parsed.builds, (_v, key) => { return ":" + key }); - builds.push("node-repl"); - builds.push("browser-repl") - return builds; -} - -export function shadowBuild() { - return state.deref().get('cljsBuild'); -} - - -function shadowCljsReplStart(buildOrRepl: string) { - if (!buildOrRepl) - return null; - if (buildOrRepl.charAt(0) == ":") - return `(shadow.cljs.devtools.api/nrepl-select ${buildOrRepl})` - else - return `(shadow.cljs.devtools.api/${buildOrRepl})` -} - -function getFigwheelMainProjects() { +function getFigwheelMainBuilds() { let chan = state.outputChannel(); - let res = fs.readdirSync(getProjectRoot()); - let projects = res.filter(x => x.match(/\.cljs\.edn/)).map(x => x.replace(/\.cljs\.edn$/, "")); - if (projects.length == 0) { - vscode.window.showErrorMessage("There are no figwheel project files (.cljs.edn) in the project directory."); - chan.appendLine("There are no figwheel project files (.cljs.edn) in the project directory."); + let res = fs.readdirSync(state.getProjectRoot()); + let builds = res.filter(x => x.match(/\.cljs\.edn/)).map(x => x.replace(/\.cljs\.edn$/, "")); + if (builds.length == 0) { + vscode.window.showErrorMessage("There are no figwheel build files (.cljs.edn) in the project directory."); + chan.appendLine("There are no figwheel build files (.cljs.edn) in the project directory."); chan.appendLine("Connection to Figwheel Main aborted."); throw "Aborted"; } - return projects; + return builds; +} + +/** + * ! DO it later + */ +function getFigwheelBuilds() { + } type checkConnectedFn = (value: string, out: any[], err: any[]) => boolean; @@ -194,20 +120,21 @@ type processOutputFn = (output: string) => void; type connectFn = (session: NReplSession, name: string, checkSuccess: checkConnectedFn) => Promise; async function evalConnectCode(newCljsSession: NReplSession, code: string, - name: string, checkSuccess: checkConnectedFn, outputProcessors?: processOutputFn[]): Promise { + name: string, checkSuccess: checkConnectedFn, outputProcessors: processOutputFn[] = [], errorProcessors: processOutputFn[] = []): Promise { let chan = state.connectionLogChannel(); let err = [], out = [], result = await newCljsSession.eval(code, { stdout: x => { - if (outputProcessors) { - for (const p of outputProcessors) { - p(x); - } - } out.push(util.stripAnsi(x)); chan.append(util.stripAnsi(x)); + for (const p of outputProcessors) { + p(util.stripAnsi(x)); + } }, stderr: x => { err.push(util.stripAnsi(x)); chan.append(util.stripAnsi(x)); + for (const p of errorProcessors) { + p(util.stripAnsi(x)); + } } }); let valueResult = await result.value @@ -226,193 +153,218 @@ async function evalConnectCode(newCljsSession: NReplSession, code: string, export interface ReplType { name: string, start?: connectFn; - started?: (valueResult: string, out: any[], err: any[]) => boolean; + started?: (valueResult: string, out: string[], err: string[]) => boolean; connect?: connectFn; - connected: (valueResult: string, out: any[], err: any[]) => boolean; + connected: (valueResult: string, out: string[], err: string[]) => boolean; } -export let cljsReplTypes: ReplType[] = [ - { - name: "Figwheel Main", - start: async (session, name, checkFn) => { - let projects = await getFigwheelMainProjects(); - let builds = projects.length <= 1 ? projects : await util.quickPickMulti({ - values: projects, - placeHolder: "Please select which builds to start", - saveAs: `${getProjectRoot()}/figwheel-main-projects` - }) - if (builds) { - state.extensionContext.workspaceState.update('cljsReplTypeHasBuilds', true); - state.cursor.set('cljsBuild', builds[0]); - const initCode = `(do (require 'figwheel.main.api) (figwheel.main.api/start ${builds.map(x => { return `"${x}"` }).join(" ")}))`; - return evalConnectCode(session, initCode, name, checkFn); - } - else { - let chan = state.outputChannel(); - chan.appendLine("Connection to Figwheel Main aborted."); - throw "Aborted"; - } - }, - started: (result, out, err) => { - return out.find((x: string) => { return x.search("Prompt will show") >= 0 }) != undefined || - err != undefined && err.find((x: string) => { - return x.search("already running") >= 0 - }); - }, - connect: async (session, name, checkFn) => { - let build = await util.quickPickSingle({ - values: await getFigwheelMainProjects(), - placeHolder: "Select which build to connect to", - saveAs: `${getProjectRoot()}/figwheel-main-build` - }); - if (build) { - state.extensionContext.workspaceState.update('cljsReplTypeHasBuilds', true); - state.cursor.set('cljsBuild', build); - const initCode = `(do (use 'figwheel.main.api) (figwheel.main.api/cljs-repl "${build}"))`; - return evalConnectCode(session, initCode, name, checkFn); - } else { - let chan = state.outputChannel(); - chan.appendLine("Connection aborted."); - throw "Aborted"; +let translatedReplType: ReplType; + +function figwheelOrShadowBuilds(cljsTypeName: string): string[] { + if (cljsTypeName.includes("Figwheel Main")) { + return getFigwheelMainBuilds(); + } else if (cljsTypeName.includes("shadow-cljs")) { + return projectTypes.shadowBuilds(); + } +} + +function updateInitCode(build: string, initCode): string { + if (build && typeof initCode === 'object') { + if (build.charAt(0) == ":") { + return initCode.build.replace("%BUILD%", build); + } else { + return initCode.repl.replace("%REPL%", build); + } + } else if (build && typeof initCode === 'string') { + return initCode.replace("%BUILD%", `"${build}"`); + } + return null; +} + +function createCLJSReplType(cljsType: CljsTypeConfig, cljsTypeName: string, connectSequence: ReplConnectSequence): ReplType { + const projectTypeName: string = connectSequence.name, + menuSelections = connectSequence.menuSelections; + let appURL: string, + haveShownStartMessage = false, + haveShownAppURL = false, + haveShownStartSuffix = false, + hasStarted = cljsType.isStarted, + useDefaultBuild = true, + startedBuilds: string[]; + const chan = state.outputChannel(), + // The output processors are used to keep the user informed about the connection process + // The output from Figwheel is meant for printing to the REPL prompt, + // and since we print to Calva says we, only print some of the messages. + printThisPrinter: processOutputFn = x => { + if (cljsType.printThisLineRegExp) { + if (x.search(cljsType.printThisLineRegExp) >= 0) { + chan.appendLine(x.replace(/\s*$/, "")); + } } }, - connected: (_result, out, _err) => { - return out.find((x: string) => { return x.search("To quit, type: :cljs/quit") >= 0 }) != undefined - } - }, - { - name: "Figwheel", - connect: async (session, name, checkFn) => { - state.extensionContext.workspaceState.update('cljsReplTypeHasBuilds', false); - state.cursor.set('cljsBuild', null); - const initCode = "(do (use 'figwheel-sidecar.repl-api) (if (not (figwheel-sidecar.repl-api/figwheel-running?)) (figwheel-sidecar.repl-api/start-figwheel!)) (figwheel-sidecar.repl-api/cljs-repl))"; - return evalConnectCode(session, initCode, name, checkFn, - [(output) => { - let matched = output.match(/Figwheel: Starting server at (.*)/); - if (matched && matched.length > 1) { - let chan = state.outputChannel(); - chan.appendLine(matched[0]); - if (state.config().openBrowserWhenFigwheelStarted) { - chan.appendLine("Opening Figwheel app in the browser (this automatic behaviour can be disabled using Settings) ..."); - open(matched[1]).catch(reason => { - console.error("Error opening Figwheel app in browser: ", reason); - }); - } else { - chan.appendLine("Not automaticaly opening Figwheel app in the browser (this can be disabled using Settings)."); - } - chan.appendLine("The CLJS REPL session will be connected when the Figwheel app has been started in the browser."); + // Having and app to connect to is crucial so we do what we can to help the user + // start the app at the right time in the process. + startAppNowProcessor: processOutputFn = x => { + // Extract the appURL if we have the regexp for it configured. + if (cljsType.openUrlRegExp) { + const matched = util.stripAnsi(x).match(cljsType.openUrlRegExp); + if (matched && matched["groups"] && matched["groups"].url != undefined) { + if (matched["groups"].url != appURL) { + appURL = matched["groups"].url; + haveShownAppURL = false; } - }]); + } + } + // When the app is ready to start, say so. + if (!haveShownStartMessage && cljsType.isReadyToStartRegExp) { + if (x.search(cljsType.isReadyToStartRegExp) >= 0) { + chan.appendLine("CLJS REPL ready to connect. Please, start your ClojureScript app."); + haveShownStartMessage = true; + } + } + // If we have an appURL to go with the ”start now” message, say so + if (appURL && haveShownStartMessage && !haveShownAppURL) { + if (cljsType.shouldOpenUrl) { + chan.appendLine(` Opening ClojureScript app in the browser at: ${appURL} ...`); + open(appURL).catch(reason => { + chan.appendLine("Error opening ClojureScript app in the browser: " + reason); + }); + } else { + chan.appendLine(" Open the app on this URL: " + appURL); + } + haveShownAppURL = true; + } + // Wait for any appURL to be printed before we round of the ”start now” message. + // (If we do not have the regexp for extracting the appURL, do not wait for appURL.) + if (!haveShownStartSuffix && (haveShownAppURL || (haveShownStartMessage && !cljsType.openUrlRegExp))) { + chan.appendLine(" The CLJS REPL will connect when your app is running."); + haveShownStartSuffix = true; + } }, - connected: (_result, out, _err) => { - return out.find((x: string) => { return x.search("Prompt will show") >= 0 }) != undefined + // This processor prints everything. We use it for stderr below. + allPrinter: processOutputFn = x => { + chan.appendLine(util.stripAnsi(x).replace(/\s*$/, "")); } - }, - { - name: "shadow-cljs", + + let replType: ReplType = { + name: cljsTypeName, connect: async (session, name, checkFn) => { - let build = await util.quickPickSingle({ - values: await shadowBuilds(), - placeHolder: "Select which build to connect to", - saveAs: `${getProjectRoot()}/shadow-cljs-build` - }); - if (build) { - state.extensionContext.workspaceState.update('cljsReplTypeHasBuilds', true); - state.cursor.set('cljsBuild', build); - const initCode = shadowCljsReplStart(build); - return evalConnectCode(session, initCode, name, checkFn); + state.extensionContext.workspaceState.update('cljsReplTypeHasBuilds', cljsType.buildsRequired); + let initCode = cljsType.connectCode, + build: string = null; + if (menuSelections && menuSelections.cljsDefaultBuild && useDefaultBuild) { + build = menuSelections.cljsDefaultBuild; + useDefaultBuild = false; } else { - let chan = state.outputChannel(); - chan.appendLine("Connection aborted."); - throw "Aborted"; + if ((typeof initCode === 'object' || initCode.includes("%BUILD%"))) { + build = await util.quickPickSingle({ + values: startedBuilds ? startedBuilds : figwheelOrShadowBuilds(cljsTypeName), + placeHolder: "Select which build to connect to", + saveAs: `${state.getProjectRoot()}/${cljsTypeName.replace(" ", "-")}-build`, + autoSelect: true + }); + } } - }, - connected: (result, _out, _err) => { - return result.search(/:selected/) >= 0; - } - } -]; -type customCLJSREPLType = { - name: string, - startCode: string, - tellUserToStartRegExp?: string, - printThisLineRegExp?: string, - connectedRegExp: string, -}; + if (build != null) { + initCode = updateInitCode(build, initCode); + if (!initCode) { + //TODO error message + return; + } + } -function createCustomCLJSReplType(custom: customCLJSREPLType): ReplType { - return { - name: custom.name, - connect: (session, name, checkFn) => { - const chan = state.outputChannel(); - state.extensionContext.workspaceState.update('cljsReplTypeHasBuilds', false); - state.cursor.set('cljsBuild', null); - const initCode = custom.startCode; - let outputProcessors: processOutputFn[] = []; - if (custom.tellUserToStartRegExp) { - outputProcessors.push((output) => { - if (custom.tellUserToStartRegExp) { - const matched = output.match(custom.tellUserToStartRegExp); - if (matched && matched.length > 0) { - chan.appendLine("CLJS REPL ready to connect. Please, start your ClojureScript app."); - chan.appendLine(" The CLJS REPL will connect when your app is running."); - } - } - }); + if (!(typeof initCode == 'string')) { + //TODO error message + return; } - if (custom.printThisLineRegExp) { - outputProcessors.push((output) => { - if (custom.printThisLineRegExp) { - const matched = output.match(`.*(${custom.printThisLineRegExp}).*`); - if (matched && matched.length > 0) { - chan.appendLine(util.stripAnsi(matched[0])); + + state.cursor.set('cljsBuild', build); + + return evalConnectCode(session, initCode, name, checkFn, [startAppNowProcessor, printThisPrinter], [allPrinter]); + }, + connected: (result, out, err) => { + if (cljsType.isConnectedRegExp) { + return [...out, result].find(x => { + return x.search(cljsType.isConnectedRegExp) >= 0 + }) != undefined; + } else { + return true; + } + } + }; + + if (cljsType.startCode) { + replType.start = async (session, name, checkFn) => { + let startCode = cljsType.startCode; + if (!hasStarted) { + if (startCode.includes("%BUILDS")) { + let builds: string[]; + if (menuSelections && menuSelections.cljsLaunchBuilds) { + builds = menuSelections.cljsLaunchBuilds; + } + else { + const allBuilds = figwheelOrShadowBuilds(cljsTypeName); + builds = allBuilds.length <= 1 ? allBuilds : await util.quickPickMulti({ + values: allBuilds, + placeHolder: "Please select which builds to start", + saveAs: `${state.getProjectRoot()}/${cljsTypeName.replace(" ", "-")}-builds` + }); + } + if (builds) { + chan.appendLine("Starting cljs repl for: " + projectTypeName + "..."); + state.extensionContext.workspaceState.update('cljsReplTypeHasBuilds', true); + startCode = startCode.replace("%BUILDS%", builds.map(x => { return `"${x}"` }).join(" ")); + const result = evalConnectCode(session, startCode, name, checkFn, [startAppNowProcessor, printThisPrinter], [allPrinter]); + if (result) { + startedBuilds = builds; } + return result; + } else { + chan.appendLine("Aborted starting cljs repl."); + throw "Aborted"; } - }); + } else { + chan.appendLine("Starting cljs repl for: " + projectTypeName + "..."); + return evalConnectCode(session, startCode, name, checkFn, [startAppNowProcessor, printThisPrinter], [allPrinter]); + } + } else { + return true; } - return evalConnectCode(session, initCode, name, checkFn, outputProcessors); - }, - connected: (_result, out, err) => { - return out.find((x: string) => { return x.search(custom.connectedRegExp) >= 0 }) != undefined - } + }; } -} -export function getCustomCLJSRepl(): ReplType { - const replConfig = state.config().customCljsRepl; - if (replConfig) { - return createCustomCLJSReplType(replConfig as customCLJSREPLType); - } else { - return undefined; + replType.started = (result, out, err) => { + if (cljsType.isReadyToStartRegExp && !hasStarted) { + const started = [...out, ...err].find(x => { + return x.search(cljsType.isReadyToStartRegExp) >= 0 + }) != undefined; + if (started) { + hasStarted = true; + } + return started; + } else { + hasStarted = true; + return true; + } } -} -function getCLJSReplTypes() { - let types = cljsReplTypes.slice(); - const customType = getCustomCLJSRepl(); - if (customType) { - types.push(customType); - } - return types; + return replType; } -async function makeCljsSessionClone(session, replType, replTypes: ReplType[]) { +async function makeCljsSessionClone(session, repl: ReplType, projectTypeName: string) { let chan = state.outputChannel(); - let repl: ReplType; chan.appendLine("Creating cljs repl session..."); let newCljsSession = await session.clone(); if (newCljsSession) { chan.show(true); - state.extensionContext.workspaceState.update('cljsReplType', replType); - state.analytics().logEvent("REPL", "ConnectingCLJS", replType).send(); - repl = replTypes.find(x => x.name == replType); + chan.appendLine("Connecting cljs repl: " + projectTypeName + "..."); + chan.appendLine("The Calva Connection Log might have more connection progress information."); if (repl.start != undefined) { - chan.appendLine("Starting repl for: " + repl.name + "..."); if (await repl.start(newCljsSession, repl.name, repl.started)) { state.analytics().logEvent("REPL", "StartedCLJS", repl.name).send(); - chan.appendLine("Started cljs builds"); + chan.appendLine("Cljs builds started"); newCljsSession = await session.clone(); } else { state.analytics().logEvent("REPL", "FailedStartingCLJS", repl.name).send(); @@ -421,13 +373,10 @@ async function makeCljsSessionClone(session, replType, replTypes: ReplType[]) { return [null, null]; } } - chan.appendLine("Connecting CLJS repl: " + repl.name + "..."); - chan.appendLine(" Compiling and stuff. This can take a minute or two."); - chan.appendLine(" See Calva Connection Log for detailed progress updates."); if (await repl.connect(newCljsSession, repl.name, repl.connected)) { state.analytics().logEvent("REPL", "ConnectedCLJS", repl.name).send(); state.cursor.set('cljs', cljsSession = newCljsSession); - return [cljsSession, null]; + return [cljsSession, state.deref().get('cljsBuild')]; } else { let build = state.deref().get('cljsBuild') state.analytics().logEvent("REPL", "FailedConnectingCLJS", repl.name).send(); @@ -439,7 +388,7 @@ async function makeCljsSessionClone(session, replType, replTypes: ReplType[]) { return [null, null]; } -async function promptForNreplUrlAndConnect(port, cljsTypeName, replTypes: ReplType[]) { +async function promptForNreplUrlAndConnect(port, connectSequence: ReplConnectSequence) { let current = state.deref(), chan = state.outputChannel(); @@ -456,7 +405,7 @@ async function promptForNreplUrlAndConnect(port, cljsTypeName, replTypes: ReplTy if (parsedPort && parsedPort > 0 && parsedPort < 65536) { state.cursor.set("hostname", hostname); state.cursor.set("port", parsedPort); - await connectToHost(hostname, parsedPort, cljsTypeName, replTypes); + await connectToHost(hostname, parsedPort, connectSequence); } else { chan.appendLine("Bad url: " + url); state.cursor.set('connecting', false); @@ -473,52 +422,17 @@ export let nClient: NReplClient; export let cljSession: NReplSession; export let cljsSession: NReplSession; -export function nreplPortFile(subPath: string): string { - try { - return path.resolve(getProjectRoot(), subPath); - } catch (e) { - console.log(e); - } - return subPath; -} - -export async function connect(isAutoConnect = false, isJackIn = false) { - let chan = state.outputChannel(); - let cljsTypeName: string; +export async function connect(connectSequence: ReplConnectSequence, isAutoConnect = false, isJackIn = false) { + const chan = state.outputChannel(), + cljsTypeName = projectTypes.getCljsTypeName(connectSequence); state.analytics().logEvent("REPL", "ConnectInitiated", isAutoConnect ? "auto" : "manual"); - - const types = getCLJSReplTypes(); - const CLJS_PROJECT_TYPE_NONE = "Don't load any cljs support, thanks" - if (isJackIn) { - cljsTypeName = state.extensionContext.workspaceState.get('selectedCljsTypeName'); - } else { - try { - await initProjectDir(); - } catch { - return; - } - let typeNames = types.map(x => x.name); - typeNames.splice(0, 0, CLJS_PROJECT_TYPE_NONE) - cljsTypeName = await util.quickPickSingle({ - values: typeNames, - placeHolder: "If you want ClojureScript support, please select a cljs project type", saveAs: `${getProjectRoot()}/connect-cljs-type`, autoSelect: true - }); - if (!cljsTypeName) { - state.analytics().logEvent("REPL", "ConnectInterrupted", "NoCljsProjectPicked").send(); - return; - } - } - state.analytics().logEvent("REPL", "ConnnectInitiated", cljsTypeName).send(); - if (cljsTypeName == CLJS_PROJECT_TYPE_NONE) { - cljsTypeName = ""; - } - - const portFile: string = await Promise.resolve(cljsTypeName === "shadow-cljs" ? nreplPortFile(".shadow-cljs/nrepl.port") : nreplPortFile(".nrepl-port")); + const portFile = projectTypes.nreplPortFile(connectSequence.projectType); state.extensionContext.workspaceState.update('selectedCljsTypeName', cljsTypeName); + state.extensionContext.workspaceState.update('selectedConnectSequence', connectSequence); if (fs.existsSync(portFile)) { let port = fs.readFileSync(portFile, 'utf8'); @@ -526,23 +440,41 @@ export async function connect(isAutoConnect = false, isJackIn = false) { if (isAutoConnect) { state.cursor.set("hostname", "localhost"); state.cursor.set("port", port); - await connectToHost("localhost", port, cljsTypeName, types); + await connectToHost("localhost", port, connectSequence); } else { - await promptForNreplUrlAndConnect(port, cljsTypeName, types); + await promptForNreplUrlAndConnect(port, connectSequence); } } else { chan.appendLine('No nrepl port file found. (Calva does not start the nrepl for you, yet.)'); - await promptForNreplUrlAndConnect(port, cljsTypeName, types); + await promptForNreplUrlAndConnect(port, connectSequence); } } else { - await promptForNreplUrlAndConnect(null, cljsTypeName, types); + await promptForNreplUrlAndConnect(null, connectSequence); } return true; } export default { - connect: connect, - disconnect: function (options = null, callback = () => { }) { + connectCommand: async () => { + const chan = state.outputChannel(); + // TODO: Figure out a better way to have an initializwd project directory. + try { + await state.initProjectDir(); + } catch { + return; + } + const cljTypes = await projectTypes.detectProjectTypes(), + connectSequence = await askForConnectSequence(cljTypes, 'connect-type', "ConnectInterrupted"); + if (connectSequence) { + const cljsTypeName = projectTypes.getCljsTypeName(connectSequence); + chan.appendLine(`Connecting ...`); + state.analytics().logEvent("REPL", "StandaloneConnect", `${connectSequence.name} + ${cljsTypeName}`).send(); + connect(connectSequence, false, false); + } else { + chan.appendLine("Aborting connect, error determining connect sequence.") + } + }, + disconnect: (options = null, callback = () => { }) => { ['clj', 'cljs'].forEach(sessionType => { state.cursor.set(sessionType, null); }); @@ -553,8 +485,7 @@ export default { nClient.close(); callback(); }, - nreplPortFile: nreplPortFile, - toggleCLJCSession: function () { + toggleCLJCSession: () => { let current = state.deref(); if (current.get('connected')) { @@ -566,16 +497,17 @@ export default { status.update(); } }, - recreateCljsRepl: async function () { - let current = state.deref(), - cljSession = util.getSession('clj'), + switchCljsBuild: async () => { + let cljSession = util.getSession('clj'), chan = state.outputChannel(); - const cljsTypeName = state.extensionContext.workspaceState.get('selectedCljsTypeName'); + const cljsTypeName: string = state.extensionContext.workspaceState.get('selectedCljsTypeName'), + cljTypeName: string = state.extensionContext.workspaceState.get('selectedCljTypeName'); + state.analytics().logEvent("REPL", "switchCljsBuild", cljsTypeName).send(); - let [session, shadowBuild] = await makeCljsSessionClone(cljSession, cljsTypeName, getCLJSReplTypes()); - if (session) - await setUpCljsRepl(session, chan, shadowBuild); + let [session, build] = await makeCljsSessionClone(cljSession, translatedReplType, cljTypeName); + if (session) { + await setUpCljsRepl(session, chan, build); + } status.update(); - }, - getCustomCLJSRepl: getCustomCLJSRepl + } }; diff --git a/calva/extension.ts b/calva/extension.ts index 5b21303cc..e4e5d22e3 100644 --- a/calva/extension.ts +++ b/calva/extension.ts @@ -21,6 +21,8 @@ import * as replWindow from "./repl-window"; import { format } from 'url'; import * as greetings from "./greet"; import Analytics from './analytics'; +import * as open from 'open'; + import { edit } from './paredit/utils'; function onDidSave(document) { @@ -63,10 +65,28 @@ function onDidOpen(document) { function activate(context: vscode.ExtensionContext) { state.cursor.set('analytics', new Analytics(context)); state.analytics().logPath("/start").logEvent("LifeCycle", "Started").send(); + + const chan = state.outputChannel(); + - let legacyExtension = vscode.extensions.getExtension('cospaia.clojure4vscode'), + const legacyExtension = vscode.extensions.getExtension('cospaia.clojure4vscode'), fmtExtension = vscode.extensions.getExtension('cospaia.calva-fmt'), - pareEditExtension = vscode.extensions.getExtension('cospaia.paredit-revived'); + pareEditExtension = vscode.extensions.getExtension('cospaia.paredit-revived'), + customCljsRepl = state.config().customCljsRepl, + replConnectSequences = state.config().replConnectSequences, + BUTTON_GOTO_WIKI = "Open the Wiki", + BUTTON_OK = "Got it", + WIKI_URL = "https://github.com/BetterThanTomorrow/calva/wiki/Custom-Connect-Sequences"; + + if (customCljsRepl && replConnectSequences.length == 0) { + chan.appendLine("Old customCljsRepl settings detected."); + vscode.window.showErrorMessage("Old customCljsRepl settings detected. You need to specifiy it using the new calva.customConnectSequence setting. See the Calva wiki for instructions.", ...[BUTTON_GOTO_WIKI, BUTTON_OK]) + .then(v => { + if (v == BUTTON_GOTO_WIKI) { + open(WIKI_URL); + } + }) + } if (legacyExtension) { vscode.window.showErrorMessage("Calva Legacy extension detected. Things will break. Please uninstall, or disable, the old Calva extension.", ...["Roger that. Right away!"]) @@ -87,7 +107,6 @@ function activate(context: vscode.ExtensionContext) { replWindow.activate(context); - let chan = state.outputChannel(); chan.appendLine("Calva activated."); let { lint, @@ -109,9 +128,9 @@ function activate(context: vscode.ExtensionContext) { }) })); context.subscriptions.push(vscode.commands.registerCommand('calva.jackIn', jackIn.calvaJackIn)) - context.subscriptions.push(vscode.commands.registerCommand('calva.connect', connector.connect)); + context.subscriptions.push(vscode.commands.registerCommand('calva.connect', connector.connectCommand)); context.subscriptions.push(vscode.commands.registerCommand('calva.toggleCLJCSession', connector.toggleCLJCSession)); - context.subscriptions.push(vscode.commands.registerCommand('calva.recreateCljsRepl', connector.recreateCljsRepl)); + context.subscriptions.push(vscode.commands.registerCommand('calva.switchCljsBuild', connector.switchCljsBuild)); context.subscriptions.push(vscode.commands.registerCommand('calva.selectCurrentForm', select.selectCurrentForm)); context.subscriptions.push(vscode.commands.registerCommand('calva.loadFile', () => { EvaluateMiddleWare.loadFile(); @@ -174,9 +193,12 @@ function activate(context: vscode.ExtensionContext) { vscode.commands.executeCommand("setContext", "calva:pareditValid", false); } status.update(); - if (editor && editor.document && editor.document.fileName.match(/\.clj[cs]?/).length && state.config().syncReplNamespaceToCurrentFile) { - replWindow.setREPLNamespace(util.getDocumentNamespace(editor.document)) - .catch(reasons => { console.warn(`Namespace sync failed, becauase: ${reasons}`) }); + if (editor && editor.document && editor.document.fileName) { + const fileExtIfClj = editor.document.fileName.match(/\.clj[cs]?/); + if (fileExtIfClj && fileExtIfClj.length && state.config().syncReplNamespaceToCurrentFile) { + replWindow.setREPLNamespace(util.getDocumentNamespace(editor.document)) + .catch(reasons => { console.warn(`Namespace sync failed, becauase: ${reasons}`) }); + } } })); context.subscriptions.push(vscode.workspace.onDidChangeTextDocument(annotations.onDidChangeTextDocument)) diff --git a/calva/nrepl/connectSequence.ts b/calva/nrepl/connectSequence.ts new file mode 100644 index 000000000..68c946e11 --- /dev/null +++ b/calva/nrepl/connectSequence.ts @@ -0,0 +1,229 @@ +import * as vscode from "vscode"; +import * as state from "../state"; +import * as projectTypes from './project-types'; +import * as utilities from '../utilities'; + +enum ProjectTypes { + "Leiningen" = "Leiningen", + "Clojure CLI" = "Clojure CLI", + "shadow-cljs" = "shadow-cljs" +} + +enum CljsTypes { + "Figwheel Main" = "Figwheel Main", + "lein-figwheel" = "lein-figwheel", + "shadow-cljs" = "shadow-cljs", + "Nashorn" = "Nashorn", + "User provided" = "User provided" +} + +interface CljsTypeConfig { + name: string, + dependsOn?: CljsTypes, + isStarted: boolean, + startCode?: string, + buildsRequired?: boolean, + isReadyToStartRegExp?: string | RegExp, + openUrlRegExp?: string | RegExp, + shouldOpenUrl?: boolean, + connectCode: string | Object, + isConnectedRegExp?: string | RegExp, + printThisLineRegExp?: string | RegExp +} + +interface MenuSelecions { + leinProfiles?: string[], + leinAlias?: string, + cljAliases?: string[], + cljsLaunchBuilds?: string[], + cljsDefaultBuild?: string +} + +interface ReplConnectSequence { + name: string, + projectType: ProjectTypes, + afterCLJReplJackInCode?: string, + cljsType?: CljsTypes | CljsTypeConfig, + menuSelections?: MenuSelecions, +} + +const leiningenDefaults: ReplConnectSequence[] = + [{ + name: "Leiningen", + projectType: ProjectTypes.Leiningen + }, + { + name: "Leiningen + Figwheel", + projectType: ProjectTypes.Leiningen, + cljsType: CljsTypes["lein-figwheel"] + }, + { + name: "Leiningen + Figwheel Main", + projectType: ProjectTypes.Leiningen, + cljsType: CljsTypes["Figwheel Main"] + }, + { + name: "Leiningen + Nashorn", + projectType: ProjectTypes.Leiningen, + cljsType: CljsTypes["Nashorn"] + }]; + +const cljDefaults: ReplConnectSequence[] = + [{ + name: "Clojure CLI", + projectType: ProjectTypes["Clojure CLI"] + }, + { + name: "Clojure CLI + Figwheel", + projectType: ProjectTypes["Clojure CLI"], + cljsType: CljsTypes["lein-figwheel"] + }, + { + name: "Clojure CLI + Figwheel Main", + projectType: ProjectTypes["Clojure CLI"], + cljsType: CljsTypes["Figwheel Main"] + }, + { + name: "Clojure CLI + Nashorn", + projectType: ProjectTypes["Clojure CLI"], + cljsType: CljsTypes["Nashorn"] + }]; + +const shadowCljsDefaults: ReplConnectSequence[] = [{ + name: "shadow-cljs", + projectType: ProjectTypes["shadow-cljs"], + cljsType: CljsTypes["shadow-cljs"] +}] + +const defaultSequences = { + "lein": leiningenDefaults, + "clj": cljDefaults, + "shadow-cljs": shadowCljsDefaults +}; + +const defaultCljsTypes: { [id: string]: CljsTypeConfig } = { + "Figwheel Main": { + name: "Figwheel Main", + buildsRequired: true, + isStarted: false, + startCode: `(do (require 'figwheel.main.api) (figwheel.main.api/start %BUILDS%))`, + isReadyToStartRegExp: /Prompt will show|Open(ing)? URL|already running/, + openUrlRegExp: /(Starting Server at|Open(ing)? URL) (?\S+)/, + shouldOpenUrl: false, + connectCode: `(do (use 'figwheel.main.api) (figwheel.main.api/cljs-repl %BUILD%))`, + isConnectedRegExp: /To quit, type: :cljs\/quit/ + }, + "lein-figwheel": { + name: "lein-figwheel", + buildsRequired: false, + isStarted: false, + isReadyToStartRegExp: /Launching ClojureScript REPL for build/, + openUrlRegExp: /Figwheel: Starting server at (?\S+)/, + // shouldOpenUrl: will be set at use-time of this config, + connectCode: "(do (use 'figwheel-sidecar.repl-api) (if (not (figwheel-sidecar.repl-api/figwheel-running?)) (figwheel-sidecar.repl-api/start-figwheel!)) (figwheel-sidecar.repl-api/cljs-repl))", + isConnectedRegExp: /To quit, type: :cljs\/quit/ + }, + "shadow-cljs": { + name: "shadow-cljs", + buildsRequired: true, + isStarted: true, + // isReadyToStartRegExp: /To quit, type: :cljs\/quit/, + connectCode: { + build: `(shadow.cljs.devtools.api/nrepl-select %BUILD%)`, + repl: `(shadow.cljs.devtools.api/%REPL%)` + }, + shouldOpenUrl: false, + isConnectedRegExp: /:selected/ + }, + "Nashorn": { + name: "Nashorn", + buildsRequired: false, + isStarted: true, + connectCode: "(do (require 'cljs.repl.nashorn) (cider.piggieback/cljs-repl (cljs.repl.nashorn/repl-env)))", + isConnectedRegExp: "To quit, type: :cljs/quit" + } +}; + +/** Retrieve the replConnectSequences from the config */ +function getCustomConnectSequences(): ReplConnectSequence[] { + let sequences: ReplConnectSequence[] = state.config().replConnectSequences; + + for (let sequence of sequences) { + if (sequence.name == undefined || + sequence.projectType == undefined) { + + vscode.window.showWarningMessage("Check your calva.replConnectSequences. " + + "You need to supply name and projectType for every sequence. " + + "After fixing the customSequences can be used."); + + return []; + } + } + + return sequences; +} + +/** + * Retrieve the replConnectSequences and returns only that if only one was defined. + * Otherwise the user defined will be combined with the defaults one to be returned. + * @param projectType what default Sequences would be used (leiningen, clj, shadow-cljs) + */ +function getConnectSequences(projectTypes: string[]): ReplConnectSequence[] { + let customSequences = getCustomConnectSequences(); + + if (customSequences.length) { + return customSequences; + } else { + let result = []; + for (let pType of projectTypes) { + result = result.concat(defaultSequences[pType]); + } + return result; + } +} + +/** + * Returns the CLJS-Type description of one of the build-in. + * @param cljsType Build-in cljsType + */ +function getDefaultCljsType(cljsType: string): CljsTypeConfig { + // TODO: Find a less hacky way to get dynamic config for lein-figwheel + defaultCljsTypes["lein-figwheel"].shouldOpenUrl = state.config().openBrowserWhenFigwheelStarted; + return defaultCljsTypes[cljsType]; +} + +async function askForConnectSequence(cljTypes: string[], saveAs: string, logLabel: string): Promise { + // figure out what possible kinds of project we're in + if (cljTypes.length == 0) { + vscode.window.showErrorMessage("Cannot find project, no project.clj, deps.edn or shadow-cljs.edn."); + state.analytics().logEvent("REPL", logLabel, "FailedFindingProjectType").send(); + return; + } + + const sequences = getConnectSequences(cljTypes); + if (sequences.length > 1) { + const projectConnectSequenceName = await utilities.quickPickSingle({ + values: sequences.map(s => { return s.name }), + placeHolder: "Please select a project type", + saveAs: `${state.getProjectRoot()}/${saveAs}`, + autoSelect: true + }); + if (!projectConnectSequenceName) { + state.analytics().logEvent("REPL", logLabel, "NoProjectTypePicked").send(); + return; + } + + return sequences.find(seq => seq.name === projectConnectSequenceName); + } else { + return sequences[0]; + } +} + +export { + askForConnectSequence, + getConnectSequences, + getDefaultCljsType, + CljsTypes, + ReplConnectSequence, + CljsTypeConfig +} \ No newline at end of file diff --git a/calva/nrepl/jack-in.ts b/calva/nrepl/jack-in.ts index 87d461eb8..a275b942c 100644 --- a/calva/nrepl/jack-in.ts +++ b/calva/nrepl/jack-in.ts @@ -6,279 +6,34 @@ import * as state from "../state" import * as connector from "../connector"; import statusbar from "../statusbar"; import { parseEdn, parseForms } from "../../cljs-out/cljs-lib"; - -const isWin = /^win/.test(process.platform); - -export async function detectProjectType(): Promise { - let rootDir = connector.getProjectRoot(), - cljProjTypes = [] - for (let clj in projectTypes) { - try { - fs.accessSync(rootDir + "/" + projectTypes[clj].useWhenExists); - cljProjTypes.push(clj); - } catch (_e) { } - } - return cljProjTypes; -} - -const cliDependencies = { - "nrepl": "0.6.0", - "cider/cider-nrepl": "0.21.1", -} -const figwheelDependencies = { - "cider/piggieback": "0.4.1", - "figwheel-sidecar": "0.5.18" -} -const shadowDependencies = { - "cider/cider-nrepl": "0.21.1", -} -const leinPluginDependencies = { - "cider/cider-nrepl": "0.21.1" -} -const leinDependencies = { - "nrepl": "0.6.0", -} -const middleware = ["cider.nrepl/cider-middleware"]; -const cljsMiddleware = ["cider.piggieback/wrap-cljs-repl"]; - -const projectTypes: { [id: string]: connector.ProjectType } = { - "lein": { - name: "Leiningen", - cljsTypes: ["Figwheel", "Figwheel Main"], - cmd: "lein", - winCmd: "cmd.exe", - useWhenExists: "project.clj", - nReplPortFile: () => { - return connector.nreplPortFile(".nrepl-port"); - }, - /** Build the Commandline args for a lein-project. - * 1. Parsing the project.clj - * 2. Let the user choose a alias - * 3. Let the user choose profiles to use - * 4. Add nedded middleware deps to args - * 5. Add all profiles choosed by the user - * 6. Use alias if selected otherwise repl :headless - */ - commandLine: async (includeCljs) => { - let out: string[] = []; - let dependencies = includeCljs ? { ...leinDependencies, ...figwheelDependencies } : leinDependencies; - let keys = Object.keys(dependencies); - let data = fs.readFileSync(path.resolve(connector.getProjectRoot(), "project.clj"), 'utf8').toString(); - let parsed; - try { - parsed = parseForms(data); - } catch (e) { - vscode.window.showErrorMessage("Could not parse project.clj"); - throw e; - } - let profiles: string[] = []; - let alias: string; - const defproject = parsed.find(x => x[0] == "defproject"); - - if (defproject) { - let aliasesIndex = defproject.indexOf("aliases"); - if (aliasesIndex > -1) { - try { - let aliases: string[] = []; - const aliasesMap = defproject[aliasesIndex + 1]; - aliases = [...profiles, ...Object.keys(aliasesMap).map((v, k) => { return v })]; - if (aliases.length) { - aliases.unshift("No alias"); - alias = await utilities.quickPickSingle({ values: aliases, saveAs: `${connector.getProjectRoot()}/lein-cli-alias`, placeHolder: "Choose alias to run" }); - alias = (alias == "No alias") ? undefined : alias; - } - } catch (error) { - vscode.window.showErrorMessage("The project.clj file is not sane. " + error.message); - console.log(error); - } - } - } - - if (defproject != undefined) { - let profilesIndex = defproject.indexOf("profiles"); - if (profilesIndex > -1) { - try { - const profilesMap = defproject[profilesIndex + 1]; - profiles = [...profiles, ...Object.keys(profilesMap).map((v, k) => { return ":" + v })]; - if (profiles.length) { - profiles = await utilities.quickPickMulti({ values: profiles, saveAs: `${connector.getProjectRoot()}/lein-cli-profiles`, placeHolder: "Pick any profiles to launch with" }); - } - } catch (error) { - vscode.window.showErrorMessage("The project.clj file is not sane. " + error.message); - console.log(error); - } - } - } - - if (isWin) { - out.push("/d", "/c", "lein"); - } - const q = isWin ? '' : "'", - dQ = '"', - s = isWin ? "^ " : " "; - - for (let i = 0; i < keys.length; i++) { - let dep = keys[i]; - out.push("update-in", ":dependencies", "conj", `${q + "[" + dep + dQ + dependencies[dep] + dQ + "]" + q}`, '--'); - } - - keys = Object.keys(leinPluginDependencies); - for (let i = 0; i < keys.length; i++) { - let dep = keys[i]; - out.push("update-in", ":plugins", "conj", `${q + "[" + dep + dQ + leinPluginDependencies[dep] + dQ + "]" + q}`, '--'); - } - - const useMiddleware = includeCljs ? [...middleware, ...cljsMiddleware] : middleware; - for (let mw of useMiddleware) { - out.push("update-in", `${q + '[:repl-options' + s + ':nrepl-middleware]' + q}`, "conj", `'["${mw}"]'`, '--'); - } - - if (profiles.length) { - out.push("with-profile", profiles.map(x => `+${x.substr(1)}`).join(',')); - } - - if (alias) { - out.push(alias); - } else { - out.push("repl", ":headless"); - } - - return out; - } - }, - /* // Works but analysing the possible launch environment is unsatifactory for now, use the cli :) - "boot": { - name: "Boot", - cmd: "boot", - winCmd: "boot.exe", - useWhenExists: "build.boot", - commandLine: () => { - let out: string[] = []; - for(let dep in cliDependencies) - out.push("-d", dep+":"+cliDependencies[dep]); - return [...out, "-i", initEval, "repl"]; - } - }, - */ - "clj": { - name: "Clojure CLI", - cljsTypes: ["Figwheel", "Figwheel Main"], - cmd: "clojure", - winCmd: "powershell.exe", - useWhenExists: "deps.edn", - nReplPortFile: () => { - return connector.nreplPortFile(".nrepl-port"); - }, - /** Build the Commandline args for a clj-project. - * 1. Read the deps.edn and parsed it - * 2. Present the user all found aliases - * 3. Define needed dependencies and middlewares used by calva - * 4. Check if the selected aliases have main-opts - * 5. If main-opts in alias => just use aliases - * 6. if no main-opts => supply our own main to run nrepl with middlewares - */ - commandLine: async (includeCljs) => { - let out: string[] = []; - let data = fs.readFileSync(path.join(connector.getProjectRoot(), "deps.edn"), 'utf8').toString(); - let parsed; - try { - parsed = parseEdn(data); - } catch (e) { - vscode.window.showErrorMessage("Could not parse deps.edn"); - throw e; - } - let aliases:string[] = []; - if (parsed.aliases != undefined) { - aliases = await utilities.quickPickMulti({ values: Object.keys(parsed.aliases).map(x => ":" + x), saveAs: `${connector.getProjectRoot()}/clj-cli-aliases`, placeHolder: "Pick any aliases to launch with" }); - } - - const dependencies = includeCljs ? { ...cliDependencies, ...figwheelDependencies } : cliDependencies, - useMiddleware = includeCljs ? [...middleware, ...cljsMiddleware] : middleware; - const aliasesOption = aliases.length > 0 ? `-A${aliases.join("")}` : ''; - let aliasHasMain:boolean = false; - for (let ali in aliases) { - let aliasKey = aliases[ali].substr(1); - let alias = parsed.aliases[aliasKey]; - aliasHasMain = (alias["main-opts"] != undefined); - if (aliasHasMain) - break; - } - - const dQ = isWin ? '""' : '"'; - for (let dep in dependencies) - out.push(dep + ` {:mvn/version ${dQ}${dependencies[dep]}${dQ}}`) - - let args = ["-Sdeps", `'${"{:deps {" + out.join(' ') + "}}"}'`]; - - if (aliasHasMain) { - args.push(aliasesOption); - } else { - args.push(aliasesOption, "-m", "nrepl.cmdline", "--middleware", `"[${useMiddleware.join(' ')}]"`); - } - - if (isWin) { - args.unshift("clojure"); - } - return args; - } - }, - "shadow-cljs": { - name: "shadow-cljs", - cljsTypes: [], - cmd: "npx", - winCmd: "npx.cmd", - useWhenExists: "shadow-cljs.edn", - nReplPortFile: () => { - return connector.nreplPortFile(path.join(".shadow-cljs", "nrepl.port")); - }, - /** - * Build the Commandline args for a shadow-project. - */ - commandLine: async (_includeCljs) => { - let args: string[] = []; - for (let dep in shadowDependencies) - args.push("-d", dep + ":" + shadowDependencies[dep]); - - const shadowBuilds = await connector.shadowBuilds(); - let builds = await utilities.quickPickMulti({ values: shadowBuilds.filter(x => x[0] == ":"), placeHolder: "Select builds to start", saveAs: `${connector.getProjectRoot()}/shadowcljs-jack-in` }) - if (!builds || !builds.length) - return; - return ["shadow-cljs", ...args, "watch", ...builds]; - } - } -} - -/** Given the name of a project in project types, find that project. */ -function getProjectTypeForName(name: string) { - for (let id in projectTypes) - if (projectTypes[id].name == name) - return projectTypes[id]; -} +import { askForConnectSequence, ReplConnectSequence, CljsTypes } from "./connectSequence"; +import { stringify } from "querystring"; +import * as projectTypes from './project-types'; let watcher: fs.FSWatcher; const TASK_NAME = "Calva Jack-in"; -async function executeJackInTask(projectType: connector.ProjectType, projectTypeSelection: any, executable: string, args: any, cljTypes: string[], outputChannel: vscode.OutputChannel) { +async function executeJackInTask(projectType: projectTypes.ProjectType, projectTypeSelection: any, executable: string, args: any, cljTypes: string[], outputChannel: vscode.OutputChannel, connectSequence: ReplConnectSequence) { state.cursor.set("launching", projectTypeSelection); statusbar.update(); - const nReplPortFile = projectType.nReplPortFile(); + const nReplPortFile = projectTypes.nreplPortFile(projectType); const env = { ...process.env, ...state.config().jackInEnv } as { [key: string]: string; }; - const execution = isWin ? + const execution = projectTypes.isWin ? new vscode.ProcessExecution(executable, args, { - cwd: connector.getProjectRoot(), + cwd: state.getProjectRoot(), env: env, }) : new vscode.ShellExecution(executable, args, { - cwd: connector.getProjectRoot(), + cwd: state.getProjectRoot(), env: env, }); const taskDefinition: vscode.TaskDefinition = { - type: isWin ? "process" : "shell", + type: projectTypes.isWin ? "process" : "shell", label: "Calva: Jack-in" }; - const task = new vscode.Task(taskDefinition, connector.getProjectWsFolder(), TASK_NAME, "Calva", execution); + const task = new vscode.Task(taskDefinition, state.getProjectWsFolder(), TASK_NAME, "Calva", execution); state.analytics().logEvent("REPL", "JackInExecuting", JSON.stringify(cljTypes)).send(); @@ -289,7 +44,7 @@ async function executeJackInTask(projectType: connector.ProjectType, projectType if (watcher != undefined) { watcher.removeAllListeners(); } - + watcher = fs.watch(portFileDir, async (eventType, fileName) => { if (fileName == portFileBase) { if (!fs.existsSync(nReplPortFile)) { @@ -303,8 +58,8 @@ async function executeJackInTask(projectType: connector.ProjectType, projectType setTimeout(() => { chan.show() }, 1000); state.cursor.set("launching", null); watcher.removeAllListeners(); - await connector.connect(true, true); - chan.appendLine("Jack-in done.\nUse the VS Code task management UI to control the life cycle of the Jack-in task."); + await connector.connect(connectSequence, true, true); + chan.appendLine("Jack-in done. Use the VS Code task management UI to control the life cycle of the Jack-in task."); } }); }, (reason) => { @@ -316,56 +71,40 @@ async function executeJackInTask(projectType: connector.ProjectType, projectType export async function calvaJackIn() { const outputChannel = state.outputChannel(); try { - await connector.initProjectDir(); + await state.initProjectDir(); } catch { return; } state.analytics().logEvent("REPL", "JackInInitiated").send(); - outputChannel.appendLine("Jacking in..."); - // figure out what possible kinds of project we're in - let cljTypes = await detectProjectType(); - if (cljTypes.length == 0) { - vscode.window.showErrorMessage("Cannot find project, no project.clj, deps.edn or shadow-cljs.edn."); - state.analytics().logEvent("REPL", "JackInInterrupted", "FailedFindingProjectType").send(); + const cljTypes = await projectTypes.detectProjectTypes(); + const projectConnectSequence: ReplConnectSequence = await askForConnectSequence(cljTypes,'jack-in-type', "JackInInterrupted"); + + if (!projectConnectSequence) { + state.analytics().logEvent("REPL", "JackInInterrupted", "NoProjectTypeForBuildName").send(); + outputChannel.appendLine("Aborting Jack-in, since no project typee was selected."); return; } + + outputChannel.appendLine("Jacking in..."); - // Show a prompt to pick one if there are multiple - let menu: string[] = []; - for (const clj of cljTypes) { - menu.push(projectTypes[clj].name); - const customCljsRepl = connector.getCustomCLJSRepl(); - const cljsTypes = projectTypes[clj].cljsTypes.slice(); - if (customCljsRepl) { - cljsTypes.push(customCljsRepl.name); - } - for (const cljs of cljsTypes) { - menu.push(`${projectTypes[clj].name} + ${cljs}`); - } - } - let projectTypeSelection = await utilities.quickPickSingle({ values: menu, placeHolder: "Please select a project type", saveAs: `${connector.getProjectRoot()}/jack-in-type`, autoSelect: true }); - if (!projectTypeSelection) { - state.analytics().logEvent("REPL", "JackInInterrupted", "NoProjectTypePicked").send(); - return; - } + const projectTypeName: string = projectConnectSequence.projectType; + state.extensionContext.workspaceState.update('selectedCljTypeName', projectConnectSequence.projectType); + let selectedCljsType: CljsTypes; - // Resolve the selection to an entry in projectTypes - const projectTypeName: string = projectTypeSelection.replace(/ \+ .*$/, ""); - let projectType = getProjectTypeForName(projectTypeName); - state.extensionContext.workspaceState.update('selectedCljTypeName', projectTypeName); - let matched = projectTypeSelection.match(/ \+ (.*)$/); - const selectedCljsType = projectType.name == "shadow-cljs" ? "shadow-cljs" : matched != null ? matched[1] : ""; - state.extensionContext.workspaceState.update('selectedCljsTypeName', selectedCljsType); - if (!projectType) { - state.analytics().logEvent("REPL", "JackInInterrupted", "NoProjectTypeForBuildName").send(); - return; + if (projectConnectSequence.cljsType == undefined) { + selectedCljsType = CljsTypes["Figwheel Main"]; + } else if (typeof projectConnectSequence.cljsType == "string") { + selectedCljsType = projectConnectSequence.cljsType; + } else { + selectedCljsType = projectConnectSequence.cljsType.dependsOn; } - let executable = isWin ? projectType.winCmd : projectType.cmd; - + let projectType = projectTypes.getProjectTypeForName(projectTypeName); + let executable = projectTypes.isWin ? projectType.winCmd : projectType.cmd; // Ask the project type to build up the command line. This may prompt for further information. - let args = await projectType.commandLine(selectedCljsType != ""); + let args = await projectType.commandLine(projectConnectSequence, selectedCljsType); - executeJackInTask(projectType, projectTypeSelection, executable, args, cljTypes, outputChannel); + executeJackInTask(projectType, projectConnectSequence.name, executable, args, cljTypes, outputChannel, projectConnectSequence) + .then(() => { }, () => { }); } diff --git a/calva/nrepl/project-types.ts b/calva/nrepl/project-types.ts new file mode 100644 index 000000000..f602d9291 --- /dev/null +++ b/calva/nrepl/project-types.ts @@ -0,0 +1,386 @@ +import * as state from '../state'; +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as utilities from '../utilities'; + +import { CljsTypes, ReplConnectSequence } from './connectSequence'; +const { parseForms, parseEdn } = require('../../cljs-out/cljs-lib'); + +export const isWin = /^win/.test(process.platform); + +export type ProjectType = { + name: string; + cljsTypes: string[]; + cmd: string; + winCmd: string; + commandLine: (connectSequence: ReplConnectSequence, cljsType: CljsTypes) => any; + useWhenExists: string; + nReplPortFile: string; +}; + +export function nreplPortFile(projectType: ProjectType | string): string { + const subPath: string = typeof projectType == "string" ? getProjectTypeForName(projectType).nReplPortFile : projectType.nReplPortFile; + try { + return path.resolve(state.getProjectRoot(), subPath); + } catch (e) { + console.log(e); + } + return subPath; +} + +export function shadowConfigFile() { + return state.getProjectRoot() + '/shadow-cljs.edn'; +} + +export function shadowBuilds(): string[] { + const parsed = parseEdn(fs.readFileSync(shadowConfigFile(), 'utf8').toString()); + return [...Object.keys(parsed.builds).map((key: string) => { return ":" + key }), ...["node-repl", "browser-repl"]]; +} + + +const cliDependencies = { + "nrepl": "0.6.0", + "cider/cider-nrepl": "0.21.1", +} + +const cljsCommonDependencies = { + "cider/piggieback": "0.4.1" +} + +const cljsDependencies: { [id: string]: Object } = { + "lein-figwheel": { + "figwheel-sidecar": "0.5.18" + }, + "Figwheel Main": { + "com.bhauman/figwheel-main": "0.2.3" + }, + "shadow-cljs": { + "cider/cider-nrepl": "0.21.1", + }, + "Nashorn": { + }, + "User provided": { + } +} + +const leinPluginDependencies = { + "cider/cider-nrepl": "0.21.1" +} +const leinDependencies = { + "nrepl": "0.6.0", +} +const middleware = ["cider.nrepl/cider-middleware"]; +const cljsMiddleware = ["cider.piggieback/wrap-cljs-repl"]; + +const projectTypes: { [id: string]: ProjectType } = { + "lein": { + name: "Leiningen", + cljsTypes: ["Figwheel", "Figwheel Main"], + cmd: "lein", + winCmd: "cmd.exe", + useWhenExists: "project.clj", + nReplPortFile: ".nrepl-port", + /** Build the Commandline args for a lein-project. + * 1. Parsing the project.clj + * 2. Let the user choose a alias + * 3. Let the user choose profiles to use + * 4. Add nedded middleware deps to args + * 5. Add all profiles choosed by the user + * 6. Use alias if selected otherwise repl :headless + */ + commandLine: async (connectSequence: ReplConnectSequence, cljsType: CljsTypes) => { + let out: string[] = []; + let dependencies = { ...leinDependencies, ...(cljsType ? { ...cljsCommonDependencies, ...cljsDependencies[cljsType] } : {}) }; + let keys = Object.keys(dependencies); + let data = fs.readFileSync(path.resolve(state.getProjectRoot(), "project.clj"), 'utf8').toString(); + let parsed; + try { + parsed = parseForms(data); + } catch (e) { + vscode.window.showErrorMessage("Could not parse project.clj"); + throw e; + } + let profiles: string[] = []; + let alias: string; + const defproject = parsed.find(x => x[0] == "defproject"); + + if (defproject) { + let aliasesIndex = defproject.indexOf("aliases"); + if (aliasesIndex > -1) { + try { + const menuSelections = connectSequence.menuSelections, + leinAlias = menuSelections ? menuSelections.leinAlias : undefined; + if (leinAlias) { + alias = _unKeywordize(leinAlias); + } else if (leinAlias === null) { + alias = undefined; + } else { + let aliases: string[] = []; + const aliasesMap = defproject[aliasesIndex + 1]; + aliases = [...profiles, ...Object.keys(aliasesMap).map((v, k) => { return v })]; + if (aliases.length) { + aliases.unshift("No alias"); + alias = await utilities.quickPickSingle({ + values: aliases, + saveAs: `${state.getProjectRoot()}/lein-cli-alias`, + placeHolder: "Choose alias to launch with" + }); + alias = (alias == "No alias") ? undefined : alias; + } + } + } catch (error) { + vscode.window.showErrorMessage("The project.clj file is not sane. " + error.message); + console.log(error); + } + } + } + + if (defproject != undefined) { + const profilesIndex = defproject.indexOf("profiles"), + menuSelections = connectSequence.menuSelections, + launchProfiles = menuSelections ? menuSelections.leinProfiles : undefined; + if (launchProfiles) { + profiles = [...profiles, ...launchProfiles.map(_keywordize)]; + } else { + let projectProfiles = profilesIndex > -1 ? Object.keys(defproject[profilesIndex + 1]) : []; + const myProfiles = state.config().myLeinProfiles; + if (myProfiles && myProfiles.length) { + projectProfiles = [...projectProfiles, ...myProfiles]; + } + if (projectProfiles.length) { + profiles = [...profiles, ...projectProfiles.map(_keywordize)]; + if (profiles.length) { + profiles = await utilities.quickPickMulti({ + values: profiles, + saveAs: `${state.getProjectRoot()}/lein-cli-profiles`, + placeHolder: "Pick any profiles to launch with" + }); + } + } + } + } + + if (isWin) { + out.push("/d", "/c", "lein"); + } + const q = isWin ? '' : "'", + dQ = '"', + s = isWin ? "^ " : " "; + + for (let i = 0; i < keys.length; i++) { + let dep = keys[i]; + out.push("update-in", ":dependencies", "conj", `${q + "[" + dep + dQ + dependencies[dep] + dQ + "]" + q}`, '--'); + } + + keys = Object.keys(leinPluginDependencies); + for (let i = 0; i < keys.length; i++) { + let dep = keys[i]; + out.push("update-in", ":plugins", "conj", `${q + "[" + dep + dQ + leinPluginDependencies[dep] + dQ + "]" + q}`, '--'); + } + + const useMiddleware = [...middleware, ...(cljsType ? cljsMiddleware : [])]; + for (let mw of useMiddleware) { + out.push("update-in", `${q + '[:repl-options' + s + ':nrepl-middleware]' + q}`, "conj", `'["${mw}"]'`, '--'); + } + + if (profiles.length) { + out.push("with-profile", profiles.map(x => `+${_unKeywordize(x)}`).join(',')); + } + + if (alias) { + out.push(alias); + } else { + out.push("repl", ":headless"); + } + + return out; + } + }, + /* // Works but analysing the possible launch environment is unsatifactory for now, use the cli :) + "boot": { + name: "Boot", + cmd: "boot", + winCmd: "boot.exe", + useWhenExists: "build.boot", + commandLine: () => { + let out: string[] = []; + for(let dep in cliDependencies) + out.push("-d", dep+":"+cliDependencies[dep]); + return [...out, "-i", initEval, "repl"]; + } + }, + */ + "clj": { + name: "Clojure CLI", + cljsTypes: ["Figwheel", "Figwheel Main"], + cmd: "clojure", + winCmd: "powershell.exe", + useWhenExists: "deps.edn", + nReplPortFile: ".nrepl-port", + /** Build the Commandline args for a clj-project. + * 1. Read the deps.edn and parsed it + * 2. Present the user all found aliases + * 3. Define needed dependencies and middlewares used by calva + * 4. Check if the selected aliases have main-opts + * 5. If main-opts in alias => just use aliases + * 6. if no main-opts => supply our own main to run nrepl with middlewares + */ + commandLine: async (connectSequence, cljsType) => { + let out: string[] = []; + let data = fs.readFileSync(path.join(state.getProjectRoot(), "deps.edn"), 'utf8').toString(); + let parsed; + try { + parsed = parseEdn(data); + } catch (e) { + vscode.window.showErrorMessage("Could not parse deps.edn"); + throw e; + } + const menuSelections = connectSequence.menuSelections, + launchAliases = menuSelections ? menuSelections.cljAliases : undefined; + let aliases: string[] = []; + if (launchAliases) { + aliases = launchAliases.map(_keywordize); + } else { + let projectAliases = parsed.aliases != undefined ? Object.keys(parsed.aliases) : []; + const myAliases = state.config().myCljAliases; + if (myAliases && myAliases.length) { + projectAliases = [...projectAliases, ...myAliases]; + } + if (projectAliases.length) { + aliases = await utilities.quickPickMulti({ + values: projectAliases.map(_keywordize), + saveAs: `${state.getProjectRoot()}/clj-cli-aliases`, + placeHolder: "Pick any aliases to launch with" + }); + } + } + + const dependencies = { ...cliDependencies, ...(cljsType ? { ...cljsCommonDependencies, ...cljsDependencies[cljsType] } : {}) }, + useMiddleware = [...middleware, ...(cljsType ? cljsMiddleware : [])]; + const aliasesOption = aliases.length > 0 ? `-A${aliases.join("")}` : ''; + let aliasHasMain: boolean = false; + for (let ali in aliases) { + const aliasKey = _unKeywordize(aliases[ali]); + if (parsed.aliases) { + let alias = parsed.aliases[aliasKey]; + aliasHasMain = alias && alias["main-opts"] != undefined; + } + if (aliasHasMain) + break; + } + const dQ = isWin ? '""' : '"'; + for (let dep in dependencies) + out.push(dep + ` {:mvn/version ${dQ}${dependencies[dep]}${dQ}}`) + + let args = ["-Sdeps", `'${"{:deps {" + out.join(' ') + "}}"}'`]; + + if (aliasHasMain) { + args.push(aliasesOption); + } else { + args.push(aliasesOption, "-m", "nrepl.cmdline", "--middleware", `"[${useMiddleware.join(' ')}]"`); + } + + if (isWin) { + args.unshift("clojure"); + } + return args; + } + }, + "shadow-cljs": { + name: "shadow-cljs", + cljsTypes: [], + cmd: "npx", + winCmd: "npx.cmd", + useWhenExists: "shadow-cljs.edn", + nReplPortFile: path.join(".shadow-cljs", "nrepl.port"), + /** + * Build the Commandline args for a shadow-project. + */ + commandLine: async (connectSequence, cljsType) => { + const chan = state.outputChannel(), + dependencies = { ...(cljsType ? { ...cljsCommonDependencies, ...cljsDependencies[cljsType] } : {}) }; + let args: string[] = []; + for (let dep in dependencies) + args.push("-d", dep + ":" + dependencies[dep]); + + const foundBuilds = await shadowBuilds(), + menuSelections = connectSequence.menuSelections, + launchAliases = menuSelections ? menuSelections.cljAliases : undefined, + selectedBuilds = await utilities.quickPickMulti({ values: foundBuilds.filter(x => x[0] == ":"), placeHolder: "Select builds to start", saveAs: `${state.getProjectRoot()}/shadowcljs-jack-in` }); + + let aliases: string[] = []; + + if (launchAliases) { + aliases = launchAliases.map(_keywordize); + } // TODO do the same as clj to prompt the user with a list of aliases + + const aliasesOption = aliases.length > 0 ? `-A${aliases.join("")}` : ''; + if (aliasesOption && aliasesOption.length) { + args.push(aliasesOption); + } + + if (selectedBuilds && selectedBuilds.length) { + return ["shadow-cljs", ...args, "watch", ...selectedBuilds]; + } else { + chan.show(); + chan.appendLine("Aborting. No valid shadow-cljs build selected."); + throw "No shadow-cljs build selected" + } + } + } +} + +/** + * Prepends a `:` to a string, so it can be used as an EDN keyword. + * (Or at least made to look like one). + * @param {string} s the string to be keywordized + * @return {string} keywordized string + */ +function _keywordize(s: string): string { + return s.replace(/^[\s,:]*/, ":"); +} + +/** + * Remove the leading `:` from strings (EDN keywords)' + * NB: Does not check if the leading character is really a `:`. + * @param {string} kw + * @return {string} kw without the first character + */ +function _unKeywordize(kw: string): string { + return kw.replace(/^[\s,:]*/, "").replace(/[\s,:]*$/, "") +} + + +/** Given the name of a project in project types, find that project. */ +export function getProjectTypeForName(name: string) { + for (let id in projectTypes) + if (projectTypes[id].name == name) + return projectTypes[id]; +} + +export async function detectProjectTypes(): Promise { + let rootDir = state.getProjectRoot(), + cljProjTypes = [] + for (let clj in projectTypes) { + try { + fs.accessSync(rootDir + "/" + projectTypes[clj].useWhenExists); + cljProjTypes.push(clj); + } catch (_e) { } + } + return cljProjTypes; +} + +export function getCljsTypeName(connectSequence: ReplConnectSequence) { + let cljsTypeName: string; + if (connectSequence.cljsType == undefined) { + cljsTypeName = ""; + } else if (typeof connectSequence.cljsType == "string") { + cljsTypeName = connectSequence.cljsType; + } else if (connectSequence.cljsType.dependsOn != undefined) { + cljsTypeName = connectSequence.cljsType.dependsOn; + } else { + cljsTypeName = "custom"; + } + return cljsTypeName; +} \ No newline at end of file diff --git a/calva/repl-window.ts b/calva/repl-window.ts index 55c17219f..fdd97bb83 100644 --- a/calva/repl-window.ts +++ b/calva/repl-window.ts @@ -105,6 +105,7 @@ class REPLWindow { }) panel.iconPath = vscode.Uri.file(path.join(ctx.extensionPath, "html", "/calva-icon.png")); + // TODO: Add a custom-cljs.svg const cljTypeSlug = `clj-type-${cljType.replace(/ /, "-").toLowerCase()}`; const cljsTypeSlug = `cljs-type-${cljsType.replace(/ /, "-").toLowerCase()}`; let html = readFileSync(path.join(ctx.extensionPath, "html/index.html")).toString(); @@ -246,8 +247,8 @@ function setREPLNamespaceCommand() { setREPLNamespace(util.getDocumentNamespace(), false).catch(r => { console.error(r) }); } -async function sendTextToREPLWindow(text, ns: string, pprint: boolean) { - let wnd = await openReplWindow(util.getREPLSessionType(), true); +export async function sendTextToREPLWindow(text, ns: string, pprint: boolean) { + let wnd = await openReplWindow(util.getREPLSessionType()); if (wnd) { let oldNs = wnd.ns; if (ns && ns != oldNs) diff --git a/calva/state.ts b/calva/state.ts index 158de8f50..5b39289b6 100644 --- a/calva/state.ts +++ b/calva/state.ts @@ -2,6 +2,10 @@ import * as vscode from 'vscode'; import * as Immutable from 'immutable'; import * as ImmutableCursor from 'immutable-cursor'; import Analytics from './analytics'; +import { ReplConnectSequence } from './nrepl/connectSequence'; +import * as util from './utilities'; +import * as path from 'path'; +import * as fs from 'fs'; let extensionContext: vscode.ExtensionContext; export function setExtensionContext(context: vscode.ExtensionContext) { @@ -74,6 +78,17 @@ function reset() { data = Immutable.fromJS(initialData); } +/** + * Trims EDN alias and profile names from any surrounding whitespace or `:` characters. + * This in order to free the user from having to figure out how the name should be entered. + * @param {string} name + * @return {string} The trimmed name + */ +function _trimAliasName(name: string): string { + return name.replace(/^[\s,:]*/, "").replace(/[\s,:]*$/, "") +} + +// TODO find a way to validate the configs function config() { let configOptions = vscode.workspace.getConfiguration('calva'); return { @@ -85,11 +100,73 @@ function config() { useWSL: configOptions.get("useWSL"), syncReplNamespaceToCurrentFile: configOptions.get("syncReplNamespaceToCurrentFile"), jackInEnv: configOptions.get("jackInEnv"), - openBrowserWhenFigwheelStarted: configOptions.get("openBrowserWhenFigwheelStarted"), - customCljsRepl: configOptions.get("customCljsRepl", null) + openBrowserWhenFigwheelStarted: configOptions.get("openBrowserWhenFigwheelStarted") as boolean, + customCljsRepl: configOptions.get("customCljsRepl", null), + replConnectSequences: configOptions.get("replConnectSequences") as ReplConnectSequence[], + myLeinProfiles: configOptions.get("myLeinProfiles", []).map(_trimAliasName) as string[], + myCljAliases: configOptions.get("myCljAliases", []).map(_trimAliasName) as string[] }; } +const PROJECT_DIR_KEY = "connect.projectDir"; + +export function getProjectRoot(useCache = true): string { + if (useCache) { + return deref().get(PROJECT_DIR_KEY); + } +} + +export function getProjectWsFolder(): vscode.WorkspaceFolder { + const doc = util.getDocument({}); + return doc ? vscode.workspace.getWorkspaceFolder(doc.uri) : null; +} + +/** + * Figures out, and stores, the current clojure project root + * Also stores the WorkSpace folder for the project to be used + * when executing the Task and get proper vscode reporting. + * + * 1. If there is no file open. Stop and complain. + * 2. If there is a file open, use it to determine the project root + * by looking for project files from the file's directory and up to + * the window root (for plain folder windows) or the file's + * workspace folder root (for workspaces) to find the project root. + * + * If there is no project file found, then store either of these + * 1. the window root for plain folders + * 2. first workspace root for workspaces. + * (This situation will be detected later by the connect process.) + */ +export async function initProjectDir(): Promise { + const projectFileNames: string[] = ["project.clj", "shadow-cljs.edn", "deps.edn"], + doc = util.getDocument({}), + workspaceFolder = doc ? vscode.workspace.getWorkspaceFolder(doc.uri) : null; + if (!workspaceFolder) { + vscode.window.showErrorMessage("There is no document opened in the workspace. Aborting. Please open a file in your Clojure project and try again."); + analytics().logEvent("REPL", "JackinOrConnectInterrupted", "NoCurrentDocument").send(); + throw "There is no document opened in the workspace. Aborting."; + } else { + let rootPath: string = path.resolve(workspaceFolder.uri.fsPath); + let d = path.dirname(doc.uri.fsPath); + let prev = null; + while (d != prev) { + for (let projectFile in projectFileNames) { + const p = path.resolve(d, projectFileNames[projectFile]); + if (fs.existsSync(p)) { + rootPath = d; + break; + } + } + if (d == rootPath) { + break; + } + prev = d; + d = path.resolve(d, ".."); + } + cursor.set(PROJECT_DIR_KEY, rootPath); + } +} + export { cursor, mode, diff --git a/calva/statusbar.ts b/calva/statusbar.ts index 673ecab48..cbf90d950 100644 --- a/calva/statusbar.ts +++ b/calva/statusbar.ts @@ -33,7 +33,7 @@ function update() { connectionStatus.tooltip = "REPL connection status"; cljsBuildStatus.text = null; - cljsBuildStatus.command = "calva.recreateCljsRepl"; + cljsBuildStatus.command = "calva.switchCljsBuild"; cljsBuildStatus.tooltip = null; if (current.get('connected')) { diff --git a/package.json b/package.json index d76a3214b..aa596638d 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,7 @@ } }, "calva.customCljsRepl": { + "deprecationMessage": "This settings is deprecated. Use `cljsType` in a `calva.replConnectSequences` item instead.", "type": "object", "default": null, "description": "Configuration for custom any CLJS REPL type your project may use", @@ -207,6 +208,165 @@ "type": "boolean", "default": true, "description": "Should Calva open the Figwheel app for you when Figwheel has been started?" + }, + "calva.myLeinProfiles": { + "type": "array", + "description": "At Jack in, any profiles listed here will be added to the profiles found in the `project.clj` file.", + "items": { + "type": "string" + } + }, + "calva.myCljAliases": { + "type": "array", + "description": "At Jack in, any aliases listed here will be added to the aliases found in the projects's `deps.edn` file.", + "items": { + "type": "string" + } + }, + "calva.replConnectSequences": { + "type": "array", + "description": "For when your project needs a custom REPL connect sequence.", + "items": { + "type": "object", + "required": [ + "name", + "projectType" + ], + "properties": { + "name": { + "type": "string", + "description": "This will show up in the Jack-in quick-pick menu when you start Jack-in if you have more than one sequence configured." + }, + "projectType": { + "type": "string", + "description": "Select one of the project types supported by Calva.", + "enum": [ + "Leiningen", + "Clojure CLI", + "shadow-cljs" + ] + }, + "afterCLJReplJackInCode": { + "type": "string", + "description": "Here you can give Calva some Clojure code to evaluate in the CLJ REPL, once it has been created.", + "required": false + }, + "menuSelections": { + "type": "object", + "description": "Pre-selected menu options. If a slection is made here. Calva won't prompt for it.", + "properties": { + "leinProfiles": { + "type": "array", + "description": "At Jack-in to a Leiningen project, use these profiles to launch the repl.", + "items": { + "type": "string" + } + }, + "leinAlias": { + "description": "At Jack-in to a Leiningen project, launch with this alias. Set to null to launch with Calva's default task (a headless repl).", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "cljAliases": { + "type": "array", + "description": "At Jack-in to a Clojure CLI project, use these aliases to launch the repl.", + "items": { + "type": "string" + } + }, + "cljsLaunchBuilds": { + "type": "array", + "description": "The cljs builds to start/watch at Jack-in/comnnect.", + "items": { + "type": "string" + } + }, + "cljsDefaultBuild": { + "type": "string", + "description": "Which cljs build to acttach to at the initial connect." + } + } + }, + "cljsType": { + "description": "Either a built in type, or an object configuring a custom type. If omitted Calva will show a menu with the built-in types.", + "anyOf": [ + { + "type": "string", + "enum": [ + "Figwheel Main", + "lein-figwheel", + "shadow-cljs", + "Nashorn" + ] + }, + { + "type": "object", + "required": [ + "connectCode", + "dependsOn" + ], + "properties": { + "dependsOn": { + "type": "string", + "enum": [ + "Figwheel Main", + "lein-figwheel", + "shadow-cljs", + "Nashorn", + "User provided" + ], + "description": "The CLojureScript REPL dependencies this customization needs. NB: If it is `User provided`, then you need to provide the dependencies in the project, or launch with an alias (deps.edn), profile (Leiningen), or build (shadow-cljs) that privides the dependencies needed." + }, + "buildsRequired": { + "type": "boolean", + "description": "If the repl type requires that builds are started in order to connect to them, set this to true." + }, + "isStarted": { + "type": "boolean", + "description": "For cljs repls that Calva does not need to start, set this to true. (If you base your custom cljs repl on shadow-cljs workflow, for instance.)" + }, + "startCode": { + "type": "string", + "description": "Clojure code to be evaluated to create and/or start your custom CLJS REPL." + }, + "isReadyToStartRegExp": { + "type": "string", + "description": "A regular experession which, when matched in the stdout from the startCode evaluation, will make Calva continue with connecting the REPL, and to prompt the user to start the application. If omitted and there is startCode Calva will continue when that code is evaluated." + }, + "openUrlRegExp": { + "type": "string", + "description": "A regular expression, matched against the stdout of cljsType evaluations, for extracting the URL with which the app can be started. The expression should have a capturing group named 'url'. E.g. \\”Open URL: (?\\S+)\\”", + "default": "Open(ing)? URL (?\\S+)" + }, + "shouldOpenUrl": { + "type": "boolean", + "description": "Choose if Calva should automatically open the URL for you or not." + }, + "connectCode": { + "type": "string", + "description": "Clojure code to be evaluated to convert the REPL to a CLJS REPL that Calva can use to connect to the application." + }, + "isConnectedRegExp": { + "type": "string", + "description": "A regular experession which, when matched in the stdout of the connectCode evaluation, will tell Calva that the application is connected.", + "default": "To quit, type: :cljs/quit" + }, + "printThisLineRegExp": { + "type": "string", + "description": "A regular experession which, when matched in the stdout from any code evaluations in the cljsType, will make the matched text be printed to the Calva says Output channel." + } + } + } + ] + } + } + } } } }, @@ -411,8 +571,8 @@ "category": "Calva" }, { - "command": "calva.recreateCljsRepl", - "title": "Attach (or re-attach) a Clojurescript session", + "command": "calva.switchCljsBuild", + "title": "Switch CLJS build", "category": "Calva" }, { @@ -665,8 +825,8 @@ "key": "ctrl+alt+c ctrl+alt+s" }, { - "command": "calva.recreateCljsRepl", - "key": "ctrl+alt+c ctrl+alt+r" + "command": "calva.switchCljsBuild", + "key": "ctrl+alt+c ctrl+alt+b" }, { "command": "calva.selectCurrentForm",