diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a866b7..8a1ebe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Change Log +## [0.9.1] - 2024-02-06 + +### Fix + +- V 0.9 won't catch some errors [#183](https://github.com/crystal-lang-tools/vscode-crystal-lang/issues/183) +- Spawn problem tool if `crystal tool dependencies` failed +- General formatting + ## [0.9.0] - 2024-02-01 ### Fix diff --git a/README.md b/README.md index f6fa46a..6484169 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,83 @@ # VSCode Extension for Crystal Language ![Publish Extension](https://github.com/crystal-lang-tools/vscode-crystal-lang/workflows/Publish%20Extension/badge.svg) ---- -**This is the official version of the plugin for Crystal Language support in VS Code.** -**Remove all old versions before installing this one.** ---- This extension provides support for the [Crystal](https://github.com/crystal-lang) programming language. -![vscode-crystal-lang](https://i.imgur.com/ZxIsOWB.gif) +![vscode-crystal-lang](./images/vscode-example.gif) -## Wiki +## Requirements -1. [Requirements](https://github.com/crystal-lang-tools/vscode-crystal-lang/wiki/Requirements) -2. [Features](https://github.com/crystal-lang-tools/vscode-crystal-lang/wiki/Features) -3. [Settings](https://github.com/crystal-lang-tools/vscode-crystal-lang/wiki/Settings) -4. [Useful extensions](https://github.com/crystal-lang-tools/vscode-crystal-lang/wiki/Useful-extensions) -5. [Known issues](https://github.com/crystal-lang-tools/vscode-crystal-lang/wiki/Known-Issues) -6. [Roadmap](https://github.com/crystal-lang-tools/vscode-crystal-lang/wiki/Roadmap) +It is recommended to install the [Crystal programming language](https://crystal-lang.org/) (platform dependendant). No other dependencies are required. +For debugging support, it's recommended to follow the guide [here](https://dev.to/bcardiff/debug-crystal-in-vscode-via-codelldb-3lf). + +## Features + +- Syntax highlighting: + - Syntax highlighting support for Crystal, [Slang](https://github.com/jeromegn/slang), and [ECR](https://crystal-lang.org/api/latest/ECR.html) (contributions for other Crystal templating languages welcome) +- Auto indentation: + - Automatically indent while coding (after `do`, `if`, etc.) +- Snippets: + - Helpful code completions for common use-cases +- Formatting + - Allows for "format on save" and manual formatting, and works even if the editor isn't saved to disk +- Problems finder + - When enabled, on opening or saving a file the project will be compiled to find any problems with the code, and these problems are reported in the editor. Depending on the project this can be slow and memory intensive, so there is an option to disable it. +- Document symbols + - Allows for easier code navigation through breadcrumbs at the top of the file, as well as being able to view and jump to the documents symbols +- Peek / go to definition + - When enabled, this allows for quickly jumping to the definition(s) for a method using the `crystal tool implementations` command +- Show type on hover + - When enabled, this allows for viewing the type of a variable or return type of a method on hover, using the `crystal tool context` command +- Tasks + - Enables executing various `crystal`/`shards` commands directly from VSCode + +## Settings + +- `compiler` - set a custom absolute path for the Crystal compiler +- `definitions` - enables jump-to-definition (reload required) +- `dependencies` - use the dependencies tool to determine the main for each file, required if there's multiple entrypoints to the project, can be slow +- `flags` - flags to pass to the compiler +- `hover` - show type information on hover (reload required) +- `main` - set a main executable to use for the current project (`${workspaceRoot}/src/main.cr`) +- `problems` - runs the compiler on save and reports any issues (reload required) +- `server` - absolute path to an LSP executable to use instead of the custom features provided by this extension, like [Crystalline](https://github.com/elbywan/crystalline) (reload required) +- `shards` - set a custom absolute path for the shards executable +- `spec-explorer` - enable the built-in testing UI for specs, recommended for Crystal >= 1.11 due to `--dry-run` flag (reload required) +- `spec-tags` - specific tags to pass to the spec runner + +By default, the problems runner, hover provider, and definitions provider are turned on. This may not be ideal for larger projects due to compile times and memory usage, so it is recommended to turn them off in the vscode settings. That can be done per-project by creating a `.vscode/settings.json` file with: + +```json +// .vscode/settings.json +{ + // Turn off slow/memory-intensive features for larger projects + "crystal-lang.definitions": false, + "crystal-lang.dependencies": false, + "crystal-lang.hover": false, + "crystal-lang.problems": false, + "crystal-lang.spec-explorer": false, +} +``` + +## Supported Platforms + +This extension has been tested on / should work on the following platforms: + +- Linux (Arch / Ubuntu / Fedora) +- MacOS (Intel / Apple Silicon) +- Windows (10 Native / 10 WSL2 / 11) +- GitHub Codespaces + +## Roadmap + +These are some features that are planned or would be nice for the future of this project or others: + +- Better / faster LSP support +- Better completion algorithm +- Better symbol detection +- Integrated debugger support +- Refactored task runner ## Release Notes diff --git a/images/vscode-example.gif b/images/vscode-example.gif new file mode 100644 index 0000000..79053a2 Binary files /dev/null and b/images/vscode-example.gif differ diff --git a/package-lock.json b/package-lock.json index f414a0a..473b269 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "crystal-lang", - "version": "0.9.0-alpha.3", + "version": "0.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "crystal-lang", - "version": "0.9.0-alpha.3", + "version": "0.9.1", "license": "MIT", "dependencies": { "async-mutex": "^0.4.0", diff --git a/package.json b/package.json index f5bb9cc..ff1dace 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "crystal-lang", "displayName": "Crystal Language", "description": "The Crystal Programming Language", - "version": "0.9.0", + "version": "0.9.1", "publisher": "crystal-lang-tools", "icon": "images/icon.gif", "license": "MIT", diff --git a/src/definitions.ts b/src/definitions.ts index 27f20cd..bcb9984 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -107,6 +107,8 @@ async function spawnImplTool( const config = workspace.getConfiguration('crystal-lang'); const cursor = getCursorPath(document, position); const main = await getShardMainPath(document); + if (!main) return; + const cmd = `${shellEscape(compiler)} tool implementations -c ${shellEscape(cursor)} ${shellEscape(main)} -f json --no-color ${config.get("flags")}` const folder: WorkspaceFolder = getWorkspaceFolder(document.uri) diff --git a/src/format.ts b/src/format.ts index 871d527..23a81ad 100644 --- a/src/format.ts +++ b/src/format.ts @@ -85,14 +85,14 @@ async function spawnFormatTool(document: TextDocument): Promise { child.on('close', () => { if (err.length > 0) { - const err_resp = err.join('') + const err_resp = err.join('') + "\n" + out.join('') findProblemsRaw(err_resp, document.uri) rej(err_resp); - return; + } else { + const out_resp = out.join('') + findProblemsRaw(out_resp, document.uri) + res(out_resp); } - const out_resp = out.join('') - findProblemsRaw(out_resp, document.uri) - res(out_resp); }) }); } diff --git a/src/hover.ts b/src/hover.ts index ad39ca5..eb3a89b 100644 --- a/src/hover.ts +++ b/src/hover.ts @@ -269,6 +269,8 @@ async function spawnContextTool( // Spec files shouldn't have main set to something in src/ // but are instead their own main files const main = await getShardMainPath(document); + if (!main) return; + const cmd = `${shellEscape(compiler)} tool context -c ${shellEscape(cursor)} ${shellEscape(main)} -f json --no-color ${config.get("flags")}` const folder = getWorkspaceFolder(document.uri) diff --git a/src/macro.ts b/src/macro.ts index f32987c..1d0c1b9 100644 --- a/src/macro.ts +++ b/src/macro.ts @@ -4,49 +4,51 @@ import { crystalOutputChannel, execAsync, findProblems, getCompilerPath, getCurs export const macroOutputChannel = window.createOutputChannel("Crystal Macro", "markdown") export function registerMacroExpansion() { - commands.registerCommand('crystal-lang.showMacroExpansion', async function () { - const activeEditor = window.activeTextEditor; - const document = activeEditor.document; - const position = activeEditor.selection.active; - - const response = await spawnMacroExpandTool(document, position) - .then((resp) => { - if (resp) { - return resp - } else { - return undefined - } - }) - - if (response) { - macroOutputChannel.appendLine("```crystal\n" + response + "```\n") + commands.registerCommand('crystal-lang.showMacroExpansion', async function () { + const activeEditor = window.activeTextEditor; + const document = activeEditor.document; + const position = activeEditor.selection.active; + + const response = await spawnMacroExpandTool(document, position) + .then((resp) => { + if (resp) { + return resp } else { - macroOutputChannel.appendLine("# No macro expansion found") + return undefined } - macroOutputChannel.show() - }) + }) + + if (response) { + macroOutputChannel.appendLine("```crystal\n" + response + "```\n") + } else { + macroOutputChannel.appendLine("# No macro expansion found") + } + macroOutputChannel.show() + }) } export async function spawnMacroExpandTool(document: TextDocument, position: Position): Promise { - const compiler = await getCompilerPath(); - const main = await getShardMainPath(document); - const cursor = getCursorPath(document, position); - const folder = getWorkspaceFolder(document.uri); - const config = workspace.getConfiguration('crystal-lang'); - - const cmd = `${shellEscape(compiler)} tool expand ${shellEscape(main)} --cursor ${shellEscape(cursor)} ${config.get("flags")}` - - crystalOutputChannel.appendLine(`[Macro Expansion] (${folder.name}) $ ` + cmd) - return await execAsync(cmd, folder.uri.fsPath) - .then((response) => { - return response; + const compiler = await getCompilerPath(); + const main = await getShardMainPath(document); + if (!main) return; + + const cursor = getCursorPath(document, position); + const folder = getWorkspaceFolder(document.uri); + const config = workspace.getConfiguration('crystal-lang'); + + const cmd = `${shellEscape(compiler)} tool expand ${shellEscape(main)} --cursor ${shellEscape(cursor)} ${config.get("flags")}` + + crystalOutputChannel.appendLine(`[Macro Expansion] (${folder.name}) $ ` + cmd) + return await execAsync(cmd, folder.uri.fsPath) + .then((response) => { + return response; + }) + .catch(async (err) => { + const new_cmd = cmd + ' -f json' + await execAsync(new_cmd, folder.uri.fsPath) + .catch((err) => { + findProblems(err.stderr, document.uri) + crystalOutputChannel.appendLine(`[Macro Expansion] Error: ${err.message}`) }) - .catch(async (err) => { - const new_cmd = cmd + ' -f json' - await execAsync(new_cmd, folder.uri.fsPath) - .catch((err) => { - findProblems(err.stderr, document.uri) - crystalOutputChannel.appendLine(`[Macro Expansion] Error: ${err.message}`) - }) - }); + }); } diff --git a/src/problems.ts b/src/problems.ts index 5033059..5ce0a77 100644 --- a/src/problems.ts +++ b/src/problems.ts @@ -2,59 +2,71 @@ import { TextDocument, workspace } from "vscode"; import { setStatusBar, compiler_mutex, crystalOutputChannel, diagnosticCollection, execAsync, findProblems, getCompilerPath, getShardMainPath, getWorkspaceFolder, shellEscape } from "./tools"; export function registerProblems(): void { - workspace.onDidSaveTextDocument((e) => { - if (e.uri && e.uri.scheme === "file" && (e.fileName.endsWith(".cr") || e.fileName.endsWith(".ecr"))) { - if (compiler_mutex.isLocked()) return; - - const dispose = setStatusBar('finding problems...'); - - compiler_mutex.acquire() - .then((release) => { - spawnProblemsTool(e) - .catch((err) => { - crystalOutputChannel.appendLine(`[Problems] Error: ${JSON.stringify(err)}`) - }) - .finally(() => { - release() - }) - }) - .finally(() => { - dispose() - }) - } - }) - - return; + workspace.onDidOpenTextDocument((e) => handleDocument(e)) + workspace.onDidSaveTextDocument((e) => handleDocument(e)) + + return; +} + +/** + * Determines whether a document should have the problems tool run on it. If it should, + * acquires the compiler mutex and executes the problems tool. + * + * @param {TextDocument} document + * @return {*} {Promise} + */ +async function handleDocument(document: TextDocument): Promise { + if (document.uri && document.uri.scheme === "file" && (document.fileName.endsWith(".cr") || document.fileName.endsWith(".ecr"))) { + if (compiler_mutex.isLocked()) return; + + const dispose = setStatusBar('finding problems...'); + + compiler_mutex.acquire() + .then((release) => { + spawnProblemsTool(document) + .catch((err) => { + crystalOutputChannel.appendLine(`[Problems] Error: ${JSON.stringify(err)}`) + }) + .finally(() => { + release() + }) + }) + .finally(() => { + dispose() + }) + } } -async function spawnProblemsTool(document: TextDocument): Promise { - const compiler = await getCompilerPath(); - const main = await getShardMainPath(document); - const folder = getWorkspaceFolder(document.uri).uri.fsPath; - const config = workspace.getConfiguration('crystal-lang'); - - // If document is in a folder of the same name as the document, it will throw an - // error about not being able to use an output filename of '...' as it's a folder. - // This is probably a bug as the --no-codegen flag is set, there is no output. - // - // Error: can't use `...` as output filename because it's a directory - // - const output = process.platform === "win32" ? "nul" : "/dev/null" - - const cmd = `${shellEscape(compiler)} build ${shellEscape(main)} --no-debug --no-color --no-codegen --error-trace -f json -o ${output} ${config.get("flags")}` - - crystalOutputChannel.appendLine(`[Problems] (${getWorkspaceFolder(document.uri).name}) $ ` + cmd) - await execAsync(cmd, folder) - .then((response) => { - diagnosticCollection.clear() - crystalOutputChannel.appendLine("[Problems] No problems found.") - }).catch((err) => { - findProblems(err.stderr, document.uri) - try { - const parsed = JSON.parse(err.stderr) - crystalOutputChannel.appendLine(`[Problems] Error: ${err.stderr}`) - } catch { - crystalOutputChannel.appendLine(`[Problems] Error: ${JSON.stringify(err)}`) - } - }); +export async function spawnProblemsTool(document: TextDocument, mainFile: string = undefined): Promise { + const compiler = await getCompilerPath(); + const main = mainFile || await getShardMainPath(document); + if (!main) return; + + const folder = getWorkspaceFolder(document.uri).uri.fsPath; + const config = workspace.getConfiguration('crystal-lang'); + + // If document is in a folder of the same name as the document, it will throw an + // error about not being able to use an output filename of '...' as it's a folder. + // This is probably a bug as the --no-codegen flag is set, there is no output. + // + // Error: can't use `...` as output filename because it's a directory + // + const output = process.platform === "win32" ? "nul" : "/dev/null" + + const cmd = `${shellEscape(compiler)} build ${shellEscape(main)} --no-debug --no-color --no-codegen --error-trace -f json -o ${output} ${config.get("flags")}` + + crystalOutputChannel.appendLine(`[Problems] (${getWorkspaceFolder(document.uri).name}) $ ` + cmd) + await execAsync(cmd, folder) + .then((response) => { + diagnosticCollection.clear() + crystalOutputChannel.appendLine("[Problems] No problems found.") + }).catch((err) => { + findProblems(err.stderr, document.uri) + try { + const parsed = JSON.parse(err.stderr) + crystalOutputChannel.appendLine(`[Problems] Error: ${err.stderr}`) + } catch { + crystalOutputChannel.appendLine(`[Problems] Error: ${JSON.stringify(err)}`) + } + }); } diff --git a/src/spec.ts b/src/spec.ts index efb462d..333680c 100644 --- a/src/spec.ts +++ b/src/spec.ts @@ -9,423 +9,423 @@ import temp = require("temp"); const spec_runner_mutex = new Mutex(); enum ItemType { - File, - TestCase + File, + TestCase } export class CrystalTestingProvider { - private workspaceFolders: WorkspaceFolder[] - private controller = tests.createTestController( - 'crystalSpecs', - 'Crystal Specs' - ) - - constructor() { - this.refreshSpecWorkspaceFolders() - this.refreshTestCases() - - - workspace.onDidSaveTextDocument(e => { - if (e.uri.scheme === "file" && this.isSpecFile(e.uri.fsPath)) { - this.deleteTestItem(e.uri.fsPath); - this.getTestCases(getWorkspaceFolder(e.uri), [e.uri.fsPath]) - } - }); + private workspaceFolders: WorkspaceFolder[] + private controller = tests.createTestController( + 'crystalSpecs', + 'Crystal Specs' + ) + + constructor() { + this.refreshSpecWorkspaceFolders() + this.refreshTestCases() + + + workspace.onDidSaveTextDocument(e => { + if (e.uri.scheme === "file" && this.isSpecFile(e.uri.fsPath)) { + this.deleteTestItem(e.uri.fsPath); + this.getTestCases(getWorkspaceFolder(e.uri), [e.uri.fsPath]) + } + }); - workspace.onDidChangeWorkspaceFolders((event) => { - this.refreshSpecWorkspaceFolders() - for (var i = 0; i < event.added.length; i += 1) { - crystalOutputChannel.appendLine("Adding folder to workspace: " + event.added[i].uri.fsPath) - this.getTestCases(event.added[i]) - } - event.removed.forEach((folder) => { - crystalOutputChannel.appendLine("Removing folder from workspace: " + folder.uri.fsPath) - this.deleteWorkspaceChildren(folder) - }) - }); + workspace.onDidChangeWorkspaceFolders((event) => { + this.refreshSpecWorkspaceFolders() + for (var i = 0; i < event.added.length; i += 1) { + crystalOutputChannel.appendLine("Adding folder to workspace: " + event.added[i].uri.fsPath) + this.getTestCases(event.added[i]) + } + event.removed.forEach((folder) => { + crystalOutputChannel.appendLine("Removing folder from workspace: " + folder.uri.fsPath) + this.deleteWorkspaceChildren(folder) + }) + }); - this.controller.refreshHandler = async () => { - this.refreshSpecWorkspaceFolders() - this.controller.items.forEach((item) => { - this.controller.items.delete(item.id) - }) - this.refreshTestCases() - } + this.controller.refreshHandler = async () => { + this.refreshSpecWorkspaceFolders() + this.controller.items.forEach((item) => { + this.controller.items.delete(item.id) + }) + this.refreshTestCases() } + } + + isSpecFile(file: string): boolean { + return file.endsWith('_spec.cr') && + this.workspaceFolders.includes(getWorkspaceFolder(Uri.file(file))) + } + + refreshSpecWorkspaceFolders(): void { + let folders = [] + workspace.workspaceFolders.forEach((folder) => { + if (existsSync(`${folder.uri.fsPath}${path.sep}shard.yml`) && + existsSync(`${folder.uri.fsPath}${path.sep}spec`)) { + folders.push(folder) + } + }) + this.workspaceFolders = folders + } + + async refreshTestCases(): Promise { + return new Promise(async () => { + for (var i = 0; i < this.workspaceFolders.length; i++) { + await this.getTestCases(this.workspaceFolders[i]) + } + }) + } - isSpecFile(file: string): boolean { - return file.endsWith('_spec.cr') && - this.workspaceFolders.includes(getWorkspaceFolder(Uri.file(file))) + async getTestCases(workspace: WorkspaceFolder, paths?: string[]): Promise { + if (spec_runner_mutex.isLocked()) { + return; } - refreshSpecWorkspaceFolders(): void { - let folders = [] - workspace.workspaceFolders.forEach((folder) => { - if (existsSync(`${folder.uri.fsPath}${path.sep}shard.yml`) && - existsSync(`${folder.uri.fsPath}${path.sep}spec`)) { - folders.push(folder) - } + const release = await spec_runner_mutex.acquire(); + const dispose = setStatusBar('searching for specs...'); + + await spawnSpecTool(workspace, true, paths) + .then((junit) => { + if (junit) this.convertJunitTestcases(junit) + }).finally(() => { + release() + dispose() + }) + } + + async execTestCases(workspace: WorkspaceFolder, paths?: string[]): Promise { + return spawnSpecTool(workspace, false, paths) + } + + private deleteTestItem(id: string) { + this.controller.items.forEach((child) => { + var item = this.getChild(id, child); + if (item !== undefined) { + item.children.forEach((c) => { + item.children.delete(c.id); + }); + } else if (child.id === id) { + this.controller.items.delete(id) + } + }); + } + + runProfile = this.controller.createRunProfile('Run', TestRunProfileKind.Run, + async (request, token) => { + if (spec_runner_mutex.isLocked()) { + return; + } + + const release = await spec_runner_mutex.acquire(); + + const run = this.controller.createTestRun(request); + const start = Date.now(); + + try { + let runnerArgs = [] + this.controller.items.forEach((item) => { + let generated = this.generateRunnerArgs(item, request.include, request.exclude) + if (generated.length > 0 && !(runnerArgs.includes(generated))) { + runnerArgs = runnerArgs.concat(generated) + } }) - this.workspaceFolders = folders - } - async refreshTestCases(): Promise { - return new Promise(async () => { - for (var i = 0; i < this.workspaceFolders.length; i++) { - await this.getTestCases(this.workspaceFolders[i]) - } + let workspaces: WorkspaceFolder[] = [] + runnerArgs.forEach((arg) => { + const uri = Uri.file(arg) + const space = getWorkspaceFolder(uri) + if (space !== undefined && !workspaces.includes(space)) { + workspaces.push(space) + } }) - } - async getTestCases(workspace: WorkspaceFolder, paths?: string[]): Promise { - if (spec_runner_mutex.isLocked()) { - return; + if (token.isCancellationRequested) { + return; } - const release = await spec_runner_mutex.acquire(); - const dispose = setStatusBar('searching for specs...'); + for (var i = 0; i < workspaces.length; i++) { + let args = [] + runnerArgs.forEach((arg) => { + if (workspaces[i] == getWorkspaceFolder(Uri.file(arg)) && !(args.includes(arg))) { + args.push(arg) + } + }) + + await this.execTestCases(workspaces[i], args) + .then(result => { + if (result) this.parseTestCaseResults(result, request, run); + }).catch(err => { + crystalOutputChannel.appendLine("[Spec] Error: " + err.message) + run.end() + }); - await spawnSpecTool(workspace, true, paths) - .then((junit) => { - if (junit) this.convertJunitTestcases(junit) - }).finally(() => { - release() - dispose() - }) - } + if (token.isCancellationRequested) { + return; + } + } + } finally { + release(); + crystalOutputChannel.appendLine(`Finished execution in ${Date.now() - start}ms`) + run.end(); + } - async execTestCases(workspace: WorkspaceFolder, paths?: string[]): Promise { - return spawnSpecTool(workspace, false, paths) } - - private deleteTestItem(id: string) { - this.controller.items.forEach((child) => { - var item = this.getChild(id, child); - if (item !== undefined) { - item.children.forEach((c) => { - item.children.delete(c.id); - }); - } else if (child.id === id) { - this.controller.items.delete(id) - } - }); + ); + + private parseTestCaseResults(result: TestSuite, request: TestRunRequest, run: TestRun) { + result.testcase.forEach((testcase: TestCase) => { + let exists: TestItem = undefined; + this.controller.items.forEach((child: TestItem) => { + if (exists === undefined) { + exists = this.getChild(testcase.file + " " + testcase.name, child); + } + }); + + if (exists) { + if (!(request.include && request.include.includes(exists)) || !(request.exclude?.includes(exists))) { + if (testcase.error) { + run.failed(exists, + new TestMessage( + testcase.error.map((v) => { + return this.formatErrorMessage(v) + }).join("\n\n\n") + ), + testcase.time * 1000); + } else if (testcase.failure) { + run.failed(exists, + new TestMessage( + testcase.failure.map((v) => { + return this.formatErrorMessage(v) + }).join("\n\n\n") + ), + testcase.time * 1000); + } else { + run.passed(exists, testcase.time * 1000); + } + } + } + }); + } + + private formatErrorMessage(v: junit2json.Details): string { + return `\n ${v.message.replace("\n", "\n ")}\n\n${v.inner}` + } + + generateRunnerArgs(item: TestItem, includes: readonly TestItem[], excludes: readonly TestItem[]): string[] { + if (includes) { + if (includes.includes(item)) { + return [item.uri.fsPath] + } else { + let foundChildren = [] + item.children.forEach((child) => { + foundChildren = foundChildren.concat(this.generateRunnerArgs(child, includes, excludes)) + }) + return foundChildren + } + } else if (excludes.length > 0) { + if (excludes.includes(item)) { + return [] + } else { + let foundChildren = [] + item.children.forEach((child) => { + foundChildren = foundChildren.concat(this.generateRunnerArgs(child, includes, excludes)) + }) + return foundChildren + } + } else { + return [item.uri.fsPath] } + } - runProfile = this.controller.createRunProfile('Run', TestRunProfileKind.Run, - async (request, token) => { - if (spec_runner_mutex.isLocked()) { - return; - } - - const release = await spec_runner_mutex.acquire(); - - const run = this.controller.createTestRun(request); - const start = Date.now(); - - try { - let runnerArgs = [] - this.controller.items.forEach((item) => { - let generated = this.generateRunnerArgs(item, request.include, request.exclude) - if (generated.length > 0 && !(runnerArgs.includes(generated))) { - runnerArgs = runnerArgs.concat(generated) - } - }) - - let workspaces: WorkspaceFolder[] = [] - runnerArgs.forEach((arg) => { - const uri = Uri.file(arg) - const space = getWorkspaceFolder(uri) - if (space !== undefined && !workspaces.includes(space)) { - workspaces.push(space) - } - }) - - if (token.isCancellationRequested) { - return; - } + private deleteWorkspaceChildren(workspace: WorkspaceFolder) { + this.controller.items.forEach((child) => { + if (child.uri.fsPath.startsWith(workspace.uri.fsPath)) { + this.deleteTestItem(child.uri.fsPath) + } + }) + } - for (var i = 0; i < workspaces.length; i++) { - let args = [] - runnerArgs.forEach((arg) => { - if (workspaces[i] == getWorkspaceFolder(Uri.file(arg)) && !(args.includes(arg))) { - args.push(arg) - } - }) - - await this.execTestCases(workspaces[i], args) - .then(result => { - if (result) this.parseTestCaseResults(result, request, run); - }).catch(err => { - crystalOutputChannel.appendLine("[Spec] Error: " + err.message) - run.end() - }); - - if (token.isCancellationRequested) { - return; - } - } - } finally { - release(); - crystalOutputChannel.appendLine(`Finished execution in ${Date.now() - start}ms`) - run.end(); - } + getChild(id: string, parent: TestItem): TestItem | undefined { + let foundChild = parent.children.get(id) + if (foundChild) { + return foundChild + } + parent.children.forEach((child) => { + if (foundChild === undefined) { + foundChild = this.getChild(id, child) + } + }) + return foundChild + } + convertJunitTestcases(testsuite: TestSuite): Promise { + return new Promise((resolve, reject) => { + try { + if (testsuite.tests === 0) { + crystalOutputChannel.appendLine(`[Spec] Error: No testcases in testsuite ${JSON.stringify(testsuite)}`) + return } - ); - - private parseTestCaseResults(result: TestSuite, request: TestRunRequest, run: TestRun) { - result.testcase.forEach((testcase: TestCase) => { - let exists: TestItem = undefined; - this.controller.items.forEach((child: TestItem) => { - if (exists === undefined) { - exists = this.getChild(testcase.file + " " + testcase.name, child); - } - }); + testsuite.testcase.forEach((testcase: TestCase) => { + const item = this.controller.createTestItem( + testcase.file + " " + testcase.name, + testcase.name, + Uri.file(testcase.file) + ) + + if (testcase.hasOwnProperty('line')) { + item.range = new Range( + new Position(testcase.line - 1, 0), + new Position(testcase.line - 1, 0) + ); + } + + let fullPath = getWorkspaceFolder(Uri.file(testcase.file)).uri.fsPath + + path.sep + 'spec'; + let parent: TestItem | null = null + + // split the testcase.file and iterate over every folder in workspace + testcase.file.replace(fullPath, "").split(path.sep).filter((folder => folder !== "")).forEach((node: string) => { + // build full path of folder + fullPath += path.sep + node + + // check if folder exists in test controller + const exists = this.controller.items.get(fullPath) if (exists) { - if (!(request.include && request.include.includes(exists)) || !(request.exclude?.includes(exists))) { - if (testcase.error) { - run.failed(exists, - new TestMessage( - testcase.error.map((v) => { - return this.formatErrorMessage(v) - }).join("\n\n\n") - ), - testcase.time * 1000); - } else if (testcase.failure) { - run.failed(exists, - new TestMessage( - testcase.failure.map((v) => { - return this.formatErrorMessage(v) - }).join("\n\n\n") - ), - testcase.time * 1000); - } else { - run.passed(exists, testcase.time * 1000); - } + // if it does, get it + parent = exists + } else if (parent) { + let childMatch = null + parent.children.forEach((child) => { + if (childMatch === null && child.id === fullPath) { + childMatch = child } - } - }); - } - - private formatErrorMessage(v: junit2json.Details): string { - return `\n ${v.message.replace("\n", "\n ")}\n\n${v.inner}` - } - - generateRunnerArgs(item: TestItem, includes: readonly TestItem[], excludes: readonly TestItem[]): string[] { - if (includes) { - if (includes.includes(item)) { - return [item.uri.fsPath] + }) + + if (childMatch !== null) { + parent = childMatch + } else { + // if it doesn't and has a parent, create an item and make it a child of the parent + let child = this.controller.createTestItem(fullPath, node, Uri.file(fullPath)) + parent.children.add(child) + parent = child + } } else { - let foundChildren = [] - item.children.forEach((child) => { - foundChildren = foundChildren.concat(this.generateRunnerArgs(child, includes, excludes)) - }) - return foundChildren + // if don't already have a parent, use controller.items + let child = this.controller.createTestItem(fullPath, node, Uri.file(fullPath)) + this.controller.items.add(child) + parent = child } - } else if (excludes.length > 0) { - if (excludes.includes(item)) { - return [] - } else { - let foundChildren = [] - item.children.forEach((child) => { - foundChildren = foundChildren.concat(this.generateRunnerArgs(child, includes, excludes)) - }) - return foundChildren - } - } else { - return [item.uri.fsPath] - } - } + }) - private deleteWorkspaceChildren(workspace: WorkspaceFolder) { - this.controller.items.forEach((child) => { - if (child.uri.fsPath.startsWith(workspace.uri.fsPath)) { - this.deleteTestItem(child.uri.fsPath) - } - }) - } - - getChild(id: string, parent: TestItem): TestItem | undefined { - let foundChild = parent.children.get(id) - if (foundChild) { - return foundChild - } - parent.children.forEach((child) => { - if (foundChild === undefined) { - foundChild = this.getChild(id, child) - } + // add testcases to last parent + parent.children.add(item) }) - return foundChild - } + resolve() - convertJunitTestcases(testsuite: TestSuite): Promise { - return new Promise((resolve, reject) => { - try { - if (testsuite.tests === 0) { - crystalOutputChannel.appendLine(`[Spec] Error: No testcases in testsuite ${JSON.stringify(testsuite)}`) - return - } - - testsuite.testcase.forEach((testcase: TestCase) => { - const item = this.controller.createTestItem( - testcase.file + " " + testcase.name, - testcase.name, - Uri.file(testcase.file) - ) - - if (testcase.hasOwnProperty('line')) { - item.range = new Range( - new Position(testcase.line - 1, 0), - new Position(testcase.line - 1, 0) - ); - } - - let fullPath = getWorkspaceFolder(Uri.file(testcase.file)).uri.fsPath + - path.sep + 'spec'; - let parent: TestItem | null = null - - // split the testcase.file and iterate over every folder in workspace - testcase.file.replace(fullPath, "").split(path.sep).filter((folder => folder !== "")).forEach((node: string) => { - // build full path of folder - fullPath += path.sep + node - - // check if folder exists in test controller - const exists = this.controller.items.get(fullPath) - if (exists) { - // if it does, get it - parent = exists - } else if (parent) { - let childMatch = null - parent.children.forEach((child) => { - if (childMatch === null && child.id === fullPath) { - childMatch = child - } - }) - - if (childMatch !== null) { - parent = childMatch - } else { - // if it doesn't and has a parent, create an item and make it a child of the parent - let child = this.controller.createTestItem(fullPath, node, Uri.file(fullPath)) - parent.children.add(child) - parent = child - } - } else { - // if don't already have a parent, use controller.items - let child = this.controller.createTestItem(fullPath, node, Uri.file(fullPath)) - this.controller.items.add(child) - parent = child - } - }) - - // add testcases to last parent - parent.children.add(item) - }) - resolve() - - } catch (err) { - crystalOutputChannel.appendLine(`[Spec] Error: ${err.message}`) - reject(err); - } - }) - } + } catch (err) { + crystalOutputChannel.appendLine(`[Spec] Error: ${err.message}`) + reject(err); + } + }) + } } export type TestSuite = junit2json.TestSuite & { - tests?: number; - skipped?: number; - errors?: number; - failures?: number; - time?: number; - timestamp?: string; - hostname?: string; - testcase?: TestCase[]; + tests?: number; + skipped?: number; + errors?: number; + failures?: number; + time?: number; + timestamp?: string; + hostname?: string; + testcase?: TestCase[]; } export type TestCase = junit2json.TestCase & { - file?: string; - classname?: string; - name?: string; - line?: number; - time?: number; + file?: string; + classname?: string; + name?: string; + line?: number; + time?: number; } // Runs `crystal spec --junit temp_file` export async function spawnSpecTool( - space: WorkspaceFolder, - dry_run: boolean = false, - paths?: string[] + space: WorkspaceFolder, + dry_run: boolean = false, + paths?: string[] ): Promise { - // Get compiler stuff - const compiler = await getCompilerPath(); - const compiler_version = await getCrystalVersion(); - const config = workspace.getConfiguration('crystal-lang'); - - // create a tempfile - const tempFile = temp.path({ suffix: ".xml" }) - - // execute crystal spec - var cmd = `${shellEscape(compiler)} spec --junit_output ${shellEscape(tempFile)} --no-color ${config.get("flags")} ${config.get("spec-tags")}`; - // Only valid for Crystal >= 1.11 - if (dry_run && compiler_version.minor > 10) { - cmd += ` --dry-run` + // Get compiler stuff + const compiler = await getCompilerPath(); + const compiler_version = await getCrystalVersion(); + const config = workspace.getConfiguration('crystal-lang'); + + // create a tempfile + const tempFile = temp.path({ suffix: ".xml" }) + + // execute crystal spec + var cmd = `${shellEscape(compiler)} spec --junit_output ${shellEscape(tempFile)} --no-color ${config.get("flags")} ${config.get("spec-tags")}`; + // Only valid for Crystal >= 1.11 + if (dry_run && compiler_version.minor > 10) { + cmd += ` --dry-run` + } + if (paths) { + cmd += ` ${paths.map((i) => shellEscape(i)).join(" ")}` + } + crystalOutputChannel.appendLine(`[Spec] (${space.name}) $ ` + cmd); + + await execAsync(cmd, space.uri.fsPath).catch((err) => { + if (err.stderr) { + findProblems(err.stderr, undefined) + } else if (err.message) { + crystalOutputChannel.appendLine(`[Spec] Error: ${err.message}`) + } else { + crystalOutputChannel.appendLine(`[Spec] Error: ${err.stdout}`) } - if (paths) { - cmd += ` ${paths.map((i) => shellEscape(i)).join(" ")}` + }); + + return readSpecResults(tempFile).then(async (results) => { + return parseJunit(results); + }).catch((err) => { + if (err.message) { + crystalOutputChannel.appendLine(`[Spec] Error: ${err.message}`) + } else { + crystalOutputChannel.appendLine(`[Spec] Error: ${JSON.stringify(err)}`) } - crystalOutputChannel.appendLine(`[Spec] (${space.name}) $ ` + cmd); - - await execAsync(cmd, space.uri.fsPath).catch((err) => { - if (err.stderr) { - findProblems(err.stderr, undefined) - } else if (err.message) { - crystalOutputChannel.appendLine(`[Spec] Error: ${err.message}`) - } else { - crystalOutputChannel.appendLine(`[Spec] Error: ${err.stdout}`) - } - }); - - return readSpecResults(tempFile).then(async (results) => { - return parseJunit(results); - }).catch((err) => { - if (err.message) { - crystalOutputChannel.appendLine(`[Spec] Error: ${err.message}`) - } else { - crystalOutputChannel.appendLine(`[Spec] Error: ${JSON.stringify(err)}`) - } - }); + }); } function readSpecResults(file: string): Promise { - return new Promise((resolve, reject) => { - try { - if (!existsSync(file)) { - reject(new Error("Test results file doesn't exist")); - return; - } - - readFile(file, (error, data) => { - if (error) { - reject(new Error("Error reading test results file: " + error.message)); - } else { - resolve(data); - } - }) - } catch (err) { - reject(err); + return new Promise((resolve, reject) => { + try { + if (!existsSync(file)) { + reject(new Error("Test results file doesn't exist")); + return; + } + + readFile(file, (error, data) => { + if (error) { + reject(new Error("Error reading test results file: " + error.message)); + } else { + resolve(data); } - }) + }) + } catch (err) { + reject(err); + } + }) } function parseJunit(rawXml: Buffer): Promise { - return new Promise(async (resolve, reject) => { - try { - const output = await junit2json.parse(rawXml); - resolve(output as TestSuite); - } catch (err) { - reject(err) - } - }) + return new Promise(async (resolve, reject) => { + try { + const output = await junit2json.parse(rawXml); + resolve(output as TestSuite); + } catch (err) { + reject(err) + } + }) } diff --git a/src/tasks.ts b/src/tasks.ts index ed7ee65..6b021ef 100644 --- a/src/tasks.ts +++ b/src/tasks.ts @@ -4,42 +4,42 @@ import * as path from "path" // Copy from https://github.com/rust-lang/rls-vscode/blob/master/src/tasks.ts export function registerTasks(context: ExtensionContext): void { - workspace.onDidOpenTextDocument((doc) => DidOpenTextDocument(doc, context)) - workspace.textDocuments.forEach((doc) => DidOpenTextDocument(doc, context)) - workspace.onDidChangeWorkspaceFolders((e) => didChangeWorkspaceFolders(e, context)); + workspace.onDidOpenTextDocument((doc) => DidOpenTextDocument(doc, context)) + workspace.textDocuments.forEach((doc) => DidOpenTextDocument(doc, context)) + workspace.onDidChangeWorkspaceFolders((e) => didChangeWorkspaceFolders(e, context)); } export function DidOpenTextDocument(document: TextDocument, context: ExtensionContext): void { - if (document.languageId !== 'crystal') { - return - } - - const uri = document.uri - let folder = getWorkspaceFolder(uri) - if (!folder) { - return - } - - folder = getOuterMostWorkspaceFolder(folder); - if (!workspaces.has(folder.uri.toString())) { - const client = new CrystalTaskClient(folder) - workspaces.set(folder.uri.toString(), client) - client.start(context) - } + if (document.languageId !== 'crystal') { + return + } + + const uri = document.uri + let folder = getWorkspaceFolder(uri) + if (!folder) { + return + } + + folder = getOuterMostWorkspaceFolder(folder); + if (!workspaces.has(folder.uri.toString())) { + const client = new CrystalTaskClient(folder) + workspaces.set(folder.uri.toString(), client) + client.start(context) + } } export function getOuterMostWorkspaceFolder(folder: WorkspaceFolder): WorkspaceFolder { - const sorted = sortedWorkspaceFolders(); - for (const element of sorted) { - let uri = folder.uri.toString(); - if (uri.charAt(uri.length - 1) !== path.sep) { - uri = uri + path.sep; - } - if (uri.startsWith(element)) { - return getWorkspaceFolder(Uri.parse(element)) || folder; - } + const sorted = sortedWorkspaceFolders(); + for (const element of sorted) { + let uri = folder.uri.toString(); + if (uri.charAt(uri.length - 1) !== path.sep) { + uri = uri + path.sep; + } + if (uri.startsWith(element)) { + return getWorkspaceFolder(Uri.parse(element)) || folder; } - return folder; + } + return folder; } // This is an intermediate, lazy cache used by `getOuterMostWorkspaceFolder` @@ -47,234 +47,234 @@ export function getOuterMostWorkspaceFolder(folder: WorkspaceFolder): WorkspaceF let _sortedWorkspaceFolders: string[] | undefined; export function sortedWorkspaceFolders(): string[] { - if (!_sortedWorkspaceFolders && workspace.workspaceFolders) { - _sortedWorkspaceFolders = workspace.workspaceFolders.map(folder => { - let result = folder.uri.toString(); - if (result.charAt(result.length - 1) !== path.sep) { - result = result + path.sep; - } - return result; - }).sort( - (a, b) => { - return a.length - b.length; - } - ); - } - return _sortedWorkspaceFolders || []; + if (!_sortedWorkspaceFolders && workspace.workspaceFolders) { + _sortedWorkspaceFolders = workspace.workspaceFolders.map(folder => { + let result = folder.uri.toString(); + if (result.charAt(result.length - 1) !== path.sep) { + result = result + path.sep; + } + return result; + }).sort( + (a, b) => { + return a.length - b.length; + } + ); + } + return _sortedWorkspaceFolders || []; } export function didChangeWorkspaceFolders(e: WorkspaceFoldersChangeEvent, context: ExtensionContext): void { - _sortedWorkspaceFolders = undefined; - - // If a VSCode workspace has been added, check to see if it is part of an existing one, and - // if not, and it is a Rust project (i.e., has a Cargo.toml), then create a new client. - for (let folder of e.added) { - folder = getOuterMostWorkspaceFolder(folder); - if (workspaces.has(folder.uri.toString())) { - continue; - } - - const client = new CrystalTaskClient(folder) - workspaces.set(folder.uri.toString(), client) - client.start(context) + _sortedWorkspaceFolders = undefined; + + // If a VSCode workspace has been added, check to see if it is part of an existing one, and + // if not, and it is a Rust project (i.e., has a Cargo.toml), then create a new client. + for (let folder of e.added) { + folder = getOuterMostWorkspaceFolder(folder); + if (workspaces.has(folder.uri.toString())) { + continue; } - // If a workspace is removed which is a Crystal workspace, kill the client. - for (const folder of e.removed) { - const client = workspaces.get(folder.uri.toString()); - if (client) { - workspaces.delete(folder.uri.toString()); - client.stop() - } + const client = new CrystalTaskClient(folder) + workspaces.set(folder.uri.toString(), client) + client.start(context) + } + + // If a workspace is removed which is a Crystal workspace, kill the client. + for (const folder of e.removed) { + const client = workspaces.get(folder.uri.toString()); + if (client) { + workspaces.delete(folder.uri.toString()); + client.stop() } + } } const workspaces: Map = new Map(); class CrystalTaskClient { - readonly folder: WorkspaceFolder; - disposables: Disposable[]; - - constructor(folder: WorkspaceFolder) { - this.folder = folder - this.disposables = [] - } - - async start(context: ExtensionContext) { - this.disposables.push(this.registerTaskProvider(TaskType.Crystal)) - this.disposables.push(this.registerTaskProvider(TaskType.Shards)) - } - - async stop() { - let promise: Thenable = Promise.resolve(void 0) - return promise.then(() => { - this.disposables.forEach(d => d.dispose()) - }) - } + readonly folder: WorkspaceFolder; + disposables: Disposable[]; + + constructor(folder: WorkspaceFolder) { + this.folder = folder + this.disposables = [] + } + + async start(context: ExtensionContext) { + this.disposables.push(this.registerTaskProvider(TaskType.Crystal)) + this.disposables.push(this.registerTaskProvider(TaskType.Shards)) + } + + async stop() { + let promise: Thenable = Promise.resolve(void 0) + return promise.then(() => { + this.disposables.forEach(d => d.dispose()) + }) + } - registerTaskProvider(taskType: TaskType): Disposable { - let provider: TaskProvider = new CrystalTaskProvider(taskType, this.folder); - const disposable = tasks.registerTaskProvider(taskType, provider); - return disposable - } + registerTaskProvider(taskType: TaskType): Disposable { + let provider: TaskProvider = new CrystalTaskProvider(taskType, this.folder); + const disposable = tasks.registerTaskProvider(taskType, provider); + return disposable + } } enum TaskType { - Crystal = 'crystal', - Shards = 'shards', + Crystal = 'crystal', + Shards = 'shards', } class CrystalTaskProvider implements TaskProvider { - constructor(private _taskType: TaskType, private _workspaceFolder: WorkspaceFolder) { - } + constructor(private _taskType: TaskType, private _workspaceFolder: WorkspaceFolder) { + } - public provideTasks() { - return getCrystalTasks(this._taskType, this._workspaceFolder) - } + public provideTasks() { + return getCrystalTasks(this._taskType, this._workspaceFolder) + } - public resolveTask(_task: Task): Task | undefined { - return undefined - } + public resolveTask(_task: Task): Task | undefined { + return undefined + } } interface CrystalTaskDefinition extends TaskDefinition { - label: string - command: string - args?: Array - file?: string + label: string + command: string + args?: Array + file?: string } interface TaskConfigItem { - definition: CrystalTaskDefinition - problemMatcher: Array - group?: TaskGroup - presentationOptions?: TaskPresentationOptions + definition: CrystalTaskDefinition + problemMatcher: Array + group?: TaskGroup + presentationOptions?: TaskPresentationOptions } async function getCrystalTasks(taskType: TaskType, target: WorkspaceFolder): Promise { - const taskList = createCrystalTaskConfigItem(taskType, target) - const list = await Promise.all(taskList.map(async (def) => { - const task: Task = await createCrystalTask(def, target) - return task - })) + const taskList = createCrystalTaskConfigItem(taskType, target) + const list = await Promise.all(taskList.map(async (def) => { + const task: Task = await createCrystalTask(def, target) + return task + })) - return list + return list } async function createCrystalTask({ definition, group, presentationOptions, problemMatcher }: TaskConfigItem, target: WorkspaceFolder): Promise { - let taskBin = await getCompilerPath() - let taskArgs: Array = (definition.args !== undefined) ? definition.args : [] - if (definition.file !== undefined) { - taskArgs.push(definition.file) - } + let taskBin = await getCompilerPath() + let taskArgs: Array = (definition.args !== undefined) ? definition.args : [] + if (definition.file !== undefined) { + taskArgs.push(definition.file) + } + + let source = 'Crystal' + if (definition.type !== 'crystal') { + taskBin = await getShardsPath() + source = 'Shards' + } + + const execCmd = `${taskBin} ${definition.command} ${taskArgs}` + const execOption: ShellExecutionOptions = { + cwd: target.uri.fsPath + } + const exec = new ShellExecution(execCmd, execOption) + let label = definition.label + if (definition.type == 'crystal' && definition.file !== undefined) { + label = `${label} - ${definition.file}` + } + + const task = new Task(definition, target, label, source, exec, problemMatcher) + if (group !== undefined) { + task.group = group + } + + if (presentationOptions !== undefined) { + task.presentationOptions = presentationOptions + } + + return task +} - let source = 'Crystal' - if (definition.type !== 'crystal') { - taskBin = await getShardsPath() - source = 'Shards' - } +const CRYSTAL_TASKS: Array<{ type: string, command: string, args?: Array, group: TaskGroup }> = [ + { + type: 'crystal', + command: 'run', + group: TaskGroup.Build + }, + { + type: 'crystal', + command: 'docs', + group: TaskGroup.Clean + }, + { + type: 'crystal', + command: 'tool format', + group: TaskGroup.Build + }, + { + type: 'crystal', + command: 'tool unreachable', + group: TaskGroup.Build + }, + { + type: 'crystal', + command: 'spec', + group: TaskGroup.Test + }, + { + type: 'shards', + command: 'install', + group: TaskGroup.Build + }, + { + type: 'shards', + command: 'update', + group: TaskGroup.Build + }, + { + type: 'shards', + command: 'build', + args: [ + '--release' + ], + group: TaskGroup.Build + }, + { + type: 'shards', + command: 'prune', + group: TaskGroup.Clean + } +] - const execCmd = `${taskBin} ${definition.command} ${taskArgs}` - const execOption: ShellExecutionOptions = { - cwd: target.uri.fsPath - } - const exec = new ShellExecution(execCmd, execOption) - let label = definition.label - if (definition.type == 'crystal' && definition.file !== undefined) { - label = `${label} - ${definition.file}` +function createCrystalTaskConfigItem(taskType: TaskType, target: WorkspaceFolder): Array { + const problemMatcher = [] + const presentationOptions: TaskPresentationOptions = { + reveal: TaskRevealKind.Always, + panel: TaskPanelKind.Dedicated, + } + const mainFile = getMainFile(target) + const tasks = CRYSTAL_TASKS.filter((task) => task.type === taskType).map((opt) => { + const def: CrystalTaskDefinition = { + label: opt.command, + type: opt.type, + command: opt.command, + args: opt.args, } - const task = new Task(definition, target, label, source, exec, problemMatcher) - if (group !== undefined) { - task.group = group + if (opt.type == 'crystal' && opt.group == TaskGroup.Build) { + def.file = mainFile } - if (presentationOptions !== undefined) { - task.presentationOptions = presentationOptions + const task = { + definition: def, + problemMatcher, + group: def.group, + presentationOptions } return task -} - -const CRYSTAL_TASKS: Array<{ type: string, command: string, args?: Array, group: TaskGroup }> = [ - { - type: 'crystal', - command: 'run', - group: TaskGroup.Build - }, - { - type: 'crystal', - command: 'docs', - group: TaskGroup.Clean - }, - { - type: 'crystal', - command: 'tool format', - group: TaskGroup.Build - }, - { - type: 'crystal', - command: 'tool unreachable', - group: TaskGroup.Build - }, - { - type: 'crystal', - command: 'spec', - group: TaskGroup.Test - }, - { - type: 'shards', - command: 'install', - group: TaskGroup.Build - }, - { - type: 'shards', - command: 'update', - group: TaskGroup.Build - }, - { - type: 'shards', - command: 'build', - args: [ - '--release' - ], - group: TaskGroup.Build - }, - { - type: 'shards', - command: 'prune', - group: TaskGroup.Clean - } -] - -function createCrystalTaskConfigItem(taskType: TaskType, target: WorkspaceFolder): Array { - const problemMatcher = [] - const presentationOptions: TaskPresentationOptions = { - reveal: TaskRevealKind.Always, - panel: TaskPanelKind.Dedicated, - } - const mainFile = getMainFile(target) - const tasks = CRYSTAL_TASKS.filter((task) => task.type === taskType).map((opt) => { - const def: CrystalTaskDefinition = { - label: opt.command, - type: opt.type, - command: opt.command, - args: opt.args, - } - - if (opt.type == 'crystal' && opt.group == TaskGroup.Build) { - def.file = mainFile - } - - const task = { - definition: def, - problemMatcher, - group: def.group, - presentationOptions - } - - return task - }) + }) - return tasks + return tasks } diff --git a/src/tools.ts b/src/tools.ts index fa8a398..56739f0 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -6,6 +6,7 @@ import { Position, TextDocument, WorkspaceFolder, window, workspace, Uri, langua import * as yaml from 'yaml'; import { cwd } from 'process'; import { Mutex } from 'async-mutex'; +import { spawnProblemsTool } from './problems'; export const crystalOutputChannel = window.createOutputChannel("Crystal", "log") @@ -218,11 +219,9 @@ export async function getShardMainPath(document: TextDocument): Promise if (config.get("dependencies")) { const shardTarget = await getShardTargetForFile(document) - if (shardTarget) return shardTarget; - } - - // If this is a crystal project - if (existsSync(fp)) { + if (shardTarget.response) return shardTarget.response; + if (shardTarget.error) return; + } else if (existsSync(fp)) { const shard_yml = readFileSync(fp, 'utf-8') const shard = yaml.parse(shard_yml) as Shard; @@ -477,7 +476,7 @@ export function getWorkspaceFolder(uri: Uri): WorkspaceFolder { * @param {TextDocument} document * @return {*} {Promise} */ -export async function getShardTargetForFile(document: TextDocument): Promise { +export async function getShardTargetForFile(document: TextDocument): Promise<{ response, error }> { const compiler = await getCompilerPath(); const space = getWorkspaceFolder(document.uri); const targets = getShardYmlTargets(space); @@ -491,24 +490,30 @@ export async function getShardTargetForFile(document: TextDocument): Promise("flags")}` crystalOutputChannel.appendLine(`[Dependencies] ${space.name} $ ${cmd}`) + const targetDocument = await workspace.openTextDocument(Uri.parse(targetPath)) - const response = await execAsync(cmd, space.uri.fsPath) + const result = await execAsync(cmd, space.uri.fsPath) + .then((resp) => { + return { response: resp, error: undefined }; + }) .catch((err) => { - findProblems(err.stderr, document.uri); + spawnProblemsTool(targetDocument, target); crystalOutputChannel.appendLine(`[Dependencies] error: ${err.stderr}`); + return { response: undefined, error: err }; }) - if (!response) continue; - const dependencies = response.split(/\r?\n/) + if (result.error) return { response: undefined, error: result.error }; + if (!result) continue; + const dependencies = result.response.split(/\r?\n/) for (const line of dependencies) { - if (path.resolve(space.uri.fsPath, line) == document.uri.fsPath) { - return path.resolve(space.uri.fsPath, target); + if (path.resolve(space.uri.fsPath, line.trim()) == document.uri.fsPath) { + return { response: path.resolve(space.uri.fsPath, target), error: undefined }; } } } - return + return { response: undefined, error: true }; } /**