Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Account for multiple project roots #1609

Merged
merged 18 commits into from
Mar 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Changes to Calva.

## [Unreleased]
- Maintenance: [Update _even more_ TypeScript code to be compatible with strictNullChecks.](https://github.com/BetterThanTomorrow/calva/pull/1605)
- [Support Polylith and monorepo jack-in/connect better](https://github.com/BetterThanTomorrow/calva/issues/1254)

## [2.0.256] - 2022-03-19
- Be more graceful about that [clojure-lsp does not start in the Getting Started REPL](https://github.com/BetterThanTomorrow/calva/issues/1601)
Expand Down
21 changes: 18 additions & 3 deletions docs/site/connect.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,16 @@ Jack-in supports both CLJ and for CLJS, and has built-in configurations for **Le

It works like so:

1. Open your project root directory in VS Code.
1. Open your project in VS Code.
1. Issue the command **Start a Project REPL and Connect**: `ctrl+alt+c ctrl+alt+j`.
1. Answer the quick-pick prompts telling Calva about project types and what profiles to start. (See the [Jack-in Project Types and Profiles](https://github.com/BetterThanTomorrow/calva/wiki/Jack-In-Project-Types-and-Profiles) wiki page for more info if needed.)

See also: [Workspace Layouts](workspace-layouts.md)

!!! Note
You must have a project file, such as `project.clj` for Leiningen or `deps.edn` for deps.edn, in the directory opened in VS Code in order for jack-in to work. If, after adding the project file, you experience an error during jack-in that says something could not be located, make sure you have the correct dependencies in your project file. For example, when using the **Figwheel Main** project type, you should have `com.bhauman/figwheel-main` in your project dependencies.
!!! Note "About project roots"
You must have a project file, such as `project.clj` for Leiningen, or `deps.edn` for deps.edn, or `shadow-cljs.edn` for shadow-cljs, in the directory opened in VS Code in order for jack-in to work. If, after adding the project file, you experience an error during jack-in that says something could not be located, make sure you have the correct dependencies in your project file. For example, when using the **Figwheel Main** project type, you should have `com.bhauman/figwheel-main` in your project dependencies.

See also below, regarding [multiple projects in a workspace](#monorepos-multiple-clojure-projects-in-one-workspace)

### Aliases, Profiles, Builds

Expand Down Expand Up @@ -61,6 +63,19 @@ All this said, I still recommend you challenge the conclusion that you can't use
!!! Note
There is a Calva command for copying the Jack-in command line to the clipboard.

## Monorepos / multiple Clojure projects in one workspace

If the workspace is a monorepo, Polylith repo or just a repository with more than one Clojure project, Calva will start the connect sequence with prompting for which project to start/connect to.

![The project roots menu](images/calva-monorepo-project-roots-menu.png)

When searching for project roots in your workspace, Calva will glob for all files matching `project.clj`, `deps.edn`, or `shadow-cljs.edn`. This is done using VS Code's workspace search engine, and is very efficient. However, in a large monorepo, it is still a substantial task. In order to not waste resources Calva will exclude any directories in the setting `calva.projectRootsSearchExclude`.

![calva.projectRootsSearchExclude setting](images/calva-project-roots-search-exclude.png)

!!! Note "Exclude entry globs"
Each entry is a partial *glob* and will be part of a resulting *glob* of the form `**/{glob1,glob2,...,globN}`. This means that all directories in the workspace matching an entry will be excluded, regardless of where in the workspace they reside.

## Troubleshooting

### Command Not Found Errors When Jacking In
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 38 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,44 @@
}
}
},
"calva.projectRootsSearchExclude": {
"type": "array",
"markdownDescription": "Exclude these directories when searching for projects in the workspace during Jack-in/Connect. Each entry is a partial *glob* and will be part of a resulting *glob* of the form `**/{glob1,glob2,...,globN}`. This means that all directories in the workspace matching an entry will be excluded.",
"items": {
"type": "string"
},
"default": [
"bower_components",
".bzr",
".cache",
".ccls-cache",
".clangd",
".classpath",
".clj-kondo",
"*.code-search",
".cpcache",
"_darcs",
".DS_Store",
".ensime_cache",
".eunit",
"flow-typed",
"_FOSSIL_",
".fslckout",
".git",
".hg",
".idea",
".lsp",
"node_modules",
".pijul",
".project",
".shadow-cljs",
".stack-work",
".svn",
"target",
".tox",
".vscode"
]
},
"calva.enableJSCompletions": {
"type": "boolean",
"description": "Should Calva use suitible and bring you JavaScript completions? This is an experimental cider-nrepl feature. Disable if completions start to throw errors.",
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ function getConfig() {
useDeprecatedAliasFlag: configOptions.get<boolean>('jackIn.useDeprecatedAliasFlag'),
},
enableClojureLspOnStart: configOptions.get<boolean>('enableClojureLspOnStart'),
projectRootsSearchExclude: configOptions.get<string[]>('projectRootsSearchExclude', []),
};
}

Expand Down
37 changes: 13 additions & 24 deletions src/connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,7 +615,7 @@ export async function connect(
const bytes = await vscode.workspace.fs.readFile(portFile);
port = new TextDecoder('utf-8').decode(bytes);
} catch {
console.log('No nrepl port found');
console.info('No nrepl port found');
}
}
if (port) {
Expand All @@ -632,21 +632,12 @@ export async function connect(
await promptForNreplUrlAndConnect(port, connectSequence);
}
} catch (e) {
console.log(e);
console.error(e);
}
return true;
}

async function standaloneConnect(
context: vscode.ExtensionContext,
connectSequence: ReplConnectSequence
) {
await state.initProjectDir();
let projectDirUri = state.getProjectRootUri();
if (!projectDirUri) {
projectDirUri = await state.getOrCreateNonProjectRoot(context, true);
}
await state.initProjectDir(projectDirUri);
async function standaloneConnect(connectSequence: ReplConnectSequence) {
await outputWindow.initResultsDoc();
await outputWindow.openResultsDoc();

Expand All @@ -668,27 +659,25 @@ async function standaloneConnect(
export default {
connectNonProjectREPLCommand: async (context: vscode.ExtensionContext) => {
status.updateNeedReplUi(true);
await state.setOrCreateNonProjectRoot(context, true);
const connectSequence = await askForConnectSequence(
projectTypes.getAllProjectTypes(),
'connect-type',
'ConnectInterrupted'
);
void standaloneConnect(context, connectSequence);
void standaloneConnect(connectSequence);
},
connectCommand: async (context: vscode.ExtensionContext) => {
connectCommand: async (_context: vscode.ExtensionContext) => {
status.updateNeedReplUi(true);
// TODO: Figure out a better way to have an initialized project directory.
try {
await state.initProjectDir();
await liveShareSupport.setupLiveShareListener();
} catch {
// Could be a bae file, user makes the call
void vscode.commands.executeCommand('calva.startOrConnectRepl');
return;
}
await state.initProjectDir().catch((e) => {
void vscode.window.showErrorMessage('Failed initializing project root directory: ', e);
});
await liveShareSupport.setupLiveShareListener().catch((e) => {
console.error('Error initializing LiveShare support: ', e);
});
const cljTypes = await projectTypes.detectProjectTypes(),
connectSequence = await askForConnectSequence(cljTypes, 'connect-type', 'ConnectInterrupted');
void standaloneConnect(context, connectSequence);
void standaloneConnect(connectSequence);
},
disconnect: (
options = null,
Expand Down
41 changes: 21 additions & 20 deletions src/extension-test/integration/suite/extension-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as vscode from 'vscode';
import * as outputWindow from '../../../results-output/results-doc';
import { commands } from 'vscode';
import { getDocument } from '../../../doc-mirror';
import * as projectRoot from '../../../project-root';

void vscode.window.showInformationMessage('Tests running. Yay!');

Expand Down Expand Up @@ -43,31 +44,31 @@ suite('Extension Test Suite', () => {

test('start repl and connect (jack-in)', async function () {
console.log('start repl and connect (jack-in)');
const testUri = path.join(testUtil.testDataDir, 'test.clj');
await openFile(testUri);
const testFilePath = path.join(testUtil.testDataDir, 'test.clj');
await openFile(testFilePath);
console.log('file opened');

await state.initProjectDir();
const uri = state.getProjectRootUri();

// pre-select deps.edn as the repl connect sequence
// qps = quickPickSingle
const saveAs = `qps-${uri.toString()}/jack-in-type`;
void state.extensionContext.workspaceState.update(saveAs, 'deps.edn');
assert.equal(
state.extensionContext.workspaceState.get(saveAs),
'deps.edn',
'Connect option not set'
);
console.log('Connect option set');
const projectRootPath = await projectRoot.findClosestProjectRootPath();
const projetcRootUri = vscode.Uri.file(projectRootPath);
// Project type pre-select, qps = quickPickSingle
const saveAs = `qps-${projetcRootUri.toString()}/jack-in-type`;
await state.extensionContext.workspaceState.update(saveAs, 'deps.edn');

const res = commands.executeCommand('calva.jackIn');
// wait for the quickPick menu to be open
while (!state.extensionContext.workspaceState.get('askForConnectSequenceQuickPick')) {
await sleep(200);

// Project root quick pick
while (util.quickPickActive === undefined) {
await sleep(50);
}
console.log('picked option');
await util.quickPickActive;
await commands.executeCommand('workbench.action.acceptSelectedQuickOpenItem');

// Project type quickpick
// pre-select deps.edn as the repl connect sequence
while (util.quickPickActive === undefined) {
await sleep(50);
}
await util.quickPickActive;
await commands.executeCommand('workbench.action.acceptSelectedQuickOpenItem');

await res;
Expand All @@ -82,7 +83,7 @@ suite('Extension Test Suite', () => {
const resultsDoc = getDocument(await outputWindow.openResultsDoc());

// focus the clojure file
await vscode.workspace.openTextDocument(testUri).then((doc) =>
await vscode.workspace.openTextDocument(testFilePath).then((doc) =>
vscode.window.showTextDocument(doc, {
preserveFocus: false,
})
Expand Down
4 changes: 1 addition & 3 deletions src/file-switcher/file-switcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,7 @@ function askToCreateANewFile(dir, file) {
export async function toggleBetweenImplAndTest() {
const activeFile = getActiveTextEditor();
const openedFilename = activeFile.document.fileName;

const projectRootUri = await projectRoot.getProjectRootUri();
const projectRootPath = projectRootUri.fsPath;
const projectRootPath = await projectRoot.findClosestProjectRootPath();
const pathAfterRoot = openedFilename.replace(projectRootPath, '');
const fullFileName = pathAfterRoot.split(path.sep).slice(-1)[0];
const extension = '.' + fullFileName.split('.').pop();
Expand Down
3 changes: 1 addition & 2 deletions src/nrepl/repl-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,7 @@ export async function startStandaloneRepl(
? await fetchConfig(dramTemplate.config)
: dramTemplate.config;
const docNames = config.files.map((f) => f.path);
const tempDirUri = await state.getOrCreateNonProjectRoot(context);
await state.initProjectDir(tempDirUri);
const tempDirUri = await state.setOrCreateNonProjectRoot(context);

const storageUri = vscode.Uri.joinPath(context.globalStorageUri, 'drams');

Expand Down
85 changes: 39 additions & 46 deletions src/project-root.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,47 @@
import * as vscode from 'vscode';
import * as util from './utilities';
import * as config from './config';
import * as path from 'path';

// TODO - this module has some code common with `state`. We can refactor `state` to use this functions.

export function getProjectWsFolder(): vscode.WorkspaceFolder | undefined {
const doc = util.tryToGetDocument({});
if (doc) {
const folder = vscode.workspace.getWorkspaceFolder(doc.uri);
if (folder) {
return folder;
}
}
if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) {
return vscode.workspace.workspaceFolders[0];
}
return undefined;
export async function findProjectRootPaths() {
const projectFileNames: string[] = ['project.clj', 'shadow-cljs.edn', 'deps.edn'];
const projectFilesGlob = `**/{${projectFileNames.join(',')}}`;
const excludeDirsGlob = `**/{${config.getConfig().projectRootsSearchExclude.join(',')}}`;
const t0 = new Date().getTime();
const candidateUris = await vscode.workspace.findFiles(projectFilesGlob, excludeDirsGlob, 10000);
console.debug('glob took', new Date().getTime() - t0, 'ms');
const projectFilePaths = candidateUris.map((uri) => path.dirname(uri.fsPath));
const candidatePaths = [...new Set(projectFilePaths)].sort();
return candidatePaths;
}

export async function findProjectRootUri(
projectFileNames: string[],
doc: vscode.TextDocument | undefined,
workspaceFolder: vscode.WorkspaceFolder | undefined
): Promise<vscode.Uri | undefined> {
let searchUri = doc?.uri || workspaceFolder?.uri;
if (searchUri && !(searchUri.scheme === 'untitled')) {
let prev: vscode.Uri | undefined = undefined;
while (searchUri != prev) {
try {
for (const projectFile of projectFileNames) {
const u = vscode.Uri.joinPath(searchUri, projectFile);
try {
await vscode.workspace.fs.stat(u);
return searchUri;
} catch {
// continue regardless of error
}
}
} catch (e) {
console.error(`Problems in search for project root directory: ${e}`);
}
prev = searchUri;
searchUri = vscode.Uri.joinPath(searchUri, '..');
}
}
export async function findClosestProjectRootPath(candidatePaths?: string[]) {
const doc = util.tryToGetDocument({});
const docDir = doc && doc.uri ? path.dirname(doc.uri.fsPath) : undefined;
candidatePaths = candidatePaths ?? (await findProjectRootPaths());
const closestRootPath = docDir
? candidatePaths
.filter((p) => docDir.startsWith(p))
.sort()
.reverse()[0]
: candidatePaths[0];
return closestRootPath;
}

// stateless function to get project root uri
export async function getProjectRootUri(): Promise<any> {
const projectFileNames: string[] = ['project.clj', 'shadow-cljs.edn', 'deps.edn'];
const doc = util.tryToGetDocument({});
const workspaceFolder = getProjectWsFolder();
return findProjectRootUri(projectFileNames, doc, workspaceFolder);
export async function pickProjectRootPath(candidatePaths: string[], closestRootPath: string) {
const pickedRootPath =
candidatePaths.length < 2
? undefined
: await util.quickPickSingle({
title: 'Project root',
values: candidatePaths,
default: closestRootPath,
placeHolder: 'Multiple Clojure projects found. Please pick the one you want to use.',
saveAs: `projectRoot`,
autoSelect: true,
});
const projectRootPath = candidatePaths.includes(pickedRootPath)
? pickedRootPath
: closestRootPath;
return projectRootPath;
}
Loading