diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f60561a..8eee453a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## 0.6.9 - 2023-01-20 + +### Added + +- Natspec completions ([#342](https://github.com/NomicFoundation/hardhat-vscode/issues/342))([#343](https://github.com/NomicFoundation/hardhat-vscode/issues/343))([#296](https://github.com/NomicFoundation/hardhat-vscode/issues/296)) +- Updated parser to support latest solidity syntax + +### Fixed + +- Improve forge binary lookup ([#354](https://github.com/NomicFoundation/hardhat-vscode/pull/354)) +- Fix logic on checking workspace folder capability ([#375](https://github.com/NomicFoundation/hardhat-vscode/pull/375)) + ## 0.6.8 - 2023-01-16 ### Added diff --git a/EXTENSION.md b/EXTENSION.md index aa6ea6ab..42e0c665 100644 --- a/EXTENSION.md +++ b/EXTENSION.md @@ -50,6 +50,12 @@ Relative imports pull their suggestions from the file system based on the curren ![Import completions](https://raw.githubusercontent.com/NomicFoundation/hardhat-vscode/main/docs/gifs/import-completion.gif "Import completions") +Natspec documentation completion is also supported + +![Natspec contract completions](https://raw.githubusercontent.com/NomicFoundation/hardhat-vscode/main/docs/gifs/natspec-contract.gif "Natspec contract completion") + +![Natspec function completions](https://raw.githubusercontent.com/NomicFoundation/hardhat-vscode/main/docs/gifs/natspec-function.gif "Natspec function completion") + --- ### Navigation diff --git a/client/src/formatter/forgeFormatter.ts b/client/src/formatter/forgeFormatter.ts index 5b28ef39..80ff3e24 100644 --- a/client/src/formatter/forgeFormatter.ts +++ b/client/src/formatter/forgeFormatter.ts @@ -1,5 +1,7 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import * as vscode from "vscode"; import * as cp from "child_process"; +import { runCmd, runningOnWindows } from "../utils/os"; export async function formatDocument( document: vscode.TextDocument @@ -11,13 +13,16 @@ export async function formatDocument( lastLine.range.end ); + const forgeCommand = await resolveForgeCommand(); + const formatted = await new Promise((resolve, reject) => { const currentDocument = vscode.window.activeTextEditor?.document.uri; const rootPath = currentDocument ? vscode.workspace.getWorkspaceFolder(currentDocument)?.uri.fsPath : undefined; + const forge = cp.execFile( - "forge", + forgeCommand, ["fmt", "--raw", "-"], { cwd: rootPath }, (err, stdout) => { @@ -35,3 +40,40 @@ export async function formatDocument( return [vscode.TextEdit.replace(fullTextRange, formatted)]; } + +async function resolveForgeCommand() { + const potentialForgeCommands = ["forge"]; + + if (runningOnWindows()) { + potentialForgeCommands.push( + `${process.env.USERPROFILE}\\.cargo\\bin\\forge` + ); + } else { + potentialForgeCommands.push(`${process.env.HOME}/.foundry/bin/forge`); + } + + for (const potentialForgeCommand of potentialForgeCommands) { + try { + await runCmd(`${potentialForgeCommand} --version`); + return potentialForgeCommand; + } catch (error: any) { + if ( + error.code === 127 || // unix + error.toString().includes("is not recognized") || // windows (code: 1) + error.toString().includes("cannot find the path") // windows (code: 1) + ) { + // command not found, then try the next potential command + continue; + } else { + // command found but execution failed + throw error; + } + } + } + + throw new Error( + `Couldn't find forge binary. Performed lookup: ${JSON.stringify( + potentialForgeCommands + )}` + ); +} diff --git a/client/src/utils/os.ts b/client/src/utils/os.ts new file mode 100644 index 00000000..17cbede8 --- /dev/null +++ b/client/src/utils/os.ts @@ -0,0 +1,18 @@ +import { exec } from "child_process"; +import os from "os"; + +export async function runCmd(cmd: string, cwd?: string): Promise { + return new Promise((resolve, reject) => { + exec(cmd, { cwd }, function (error, stdout) { + if (error !== null) { + reject(error); + } + + resolve(stdout); + }); + }); +} + +export function runningOnWindows() { + return os.platform() === "win32"; +} diff --git a/coc/package.json b/coc/package.json index 023dff97..f9af0aa2 100644 --- a/coc/package.json +++ b/coc/package.json @@ -2,7 +2,7 @@ "name": "@ignored/coc-solidity", "description": "Solidity and Hardhat support for coc.nvim", "license": "MIT", - "version": "0.6.8", + "version": "0.6.9", "author": "Nomic Foundation", "repository": { "type": "git", @@ -28,7 +28,7 @@ "clean": "rimraf out .nyc_output coverage *.tsbuildinfo *.log" }, "dependencies": { - "@ignored/solidity-language-server": "0.6.8" + "@ignored/solidity-language-server": "0.6.9" }, "devDependencies": { "@types/node": "^17.0.21", diff --git a/docs/gifs/natspec-contract.gif b/docs/gifs/natspec-contract.gif new file mode 100644 index 00000000..72cd01dd Binary files /dev/null and b/docs/gifs/natspec-contract.gif differ diff --git a/docs/gifs/natspec-function.gif b/docs/gifs/natspec-function.gif new file mode 100644 index 00000000..8b53bbf4 Binary files /dev/null and b/docs/gifs/natspec-function.gif differ diff --git a/package.json b/package.json index bb3d016e..684e45d1 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "displayName": "Solidity", "description": "Solidity and Hardhat support by the Hardhat team", "license": "MIT", - "version": "0.6.8", + "version": "0.6.9", "private": true, "main": "./client/out/extension.js", "module": "./client/out/extension.js", diff --git a/server/package.json b/server/package.json index 7ac73178..2e8d25d3 100644 --- a/server/package.json +++ b/server/package.json @@ -2,7 +2,7 @@ "name": "@ignored/solidity-language-server", "description": "Solidity language server by Nomic Foundation", "license": "MIT", - "version": "0.6.8", + "version": "0.6.9", "author": "Nomic Foundation", "repository": { "type": "git", @@ -69,7 +69,7 @@ "@nomicfoundation/solidity-analyzer": "0.1.0", "@sentry/node": "6.19.1", "@sentry/tracing": "6.19.1", - "@solidity-parser/parser": "^0.14.0", + "@solidity-parser/parser": "^0.14.5", "c3-linearization": "0.3.0", "fast-glob": "3.2.11", "fs-extra": "^10.0.0", diff --git a/server/src/frameworks/Foundry/FoundryProject.ts b/server/src/frameworks/Foundry/FoundryProject.ts index 3ae038f0..e0f7e056 100644 --- a/server/src/frameworks/Foundry/FoundryProject.ts +++ b/server/src/frameworks/Foundry/FoundryProject.ts @@ -44,12 +44,12 @@ export class FoundryProject extends Project { } public async initialize(): Promise { + this.initializeError = undefined; // clear any potential error on restart + try { - const forgePath = runningOnWindows() - ? "%USERPROFILE%\\.cargo\\bin\\forge" - : "~/.foundry/bin/forge"; + const forgeCommand = await this._resolveForgeCommand(); const config = JSON.parse( - await runCmd(`${forgePath} config --json`, this.basePath) + await runCmd(`${forgeCommand} config --json`, this.basePath) ); this.sourcesPath = path.join(this.basePath, config.src); this.testsPath = path.join(this.basePath, config.test); @@ -58,7 +58,7 @@ export class FoundryProject extends Project { this.configSolcVersion = config.solc || undefined; // may come as null otherwise const rawRemappings = await runCmd( - `${forgePath} remappings`, + `${forgeCommand} remappings`, this.basePath ); this.remappings = this._parseRemappings(rawRemappings); @@ -66,14 +66,13 @@ export class FoundryProject extends Project { this.serverState.logger.error(error.toString()); switch (error.code) { - case 127: - this.initializeError = - "Couldn't run `forge`. Please check that your foundry installation is correct."; - break; case 134: this.initializeError = "Running `forge` failed. Please check that your foundry.toml file is correct."; break; + case undefined: + this.initializeError = `${error}`; + break; default: this.initializeError = `Unexpected error while running \`forge\`: ${error}`; } @@ -216,4 +215,40 @@ export class FoundryProject extends Project { return remappings; } + + // Returns the forge binary path + private async _resolveForgeCommand() { + const potentialForgeCommands = ["forge"]; + + if (runningOnWindows()) { + potentialForgeCommands.push("%USERPROFILE%\\.cargo\\bin\\forge"); + } else { + potentialForgeCommands.push("~/.foundry/bin/forge"); + } + + for (const potentialForgeCommand of potentialForgeCommands) { + try { + await runCmd(`${potentialForgeCommand} --version`); + return potentialForgeCommand; + } catch (error: any) { + if ( + error.code === 127 || // unix + error.toString().includes("is not recognized") || // windows (code: 1) + error.toString().includes("cannot find the path") // windows (code: 1) + ) { + // command not found, then try the next potential command + continue; + } else { + // command found but execution failed + throw error; + } + } + } + + throw new Error( + `Couldn't find forge binary. Performed lookup: ${JSON.stringify( + potentialForgeCommands + )}` + ); + } } diff --git a/server/src/parser/analyzer/matcher.ts b/server/src/parser/analyzer/matcher.ts index 0bffe7fa..5ea5a889 100644 --- a/server/src/parser/analyzer/matcher.ts +++ b/server/src/parser/analyzer/matcher.ts @@ -52,7 +52,6 @@ import { AssemblyIfNode } from "@analyzer/nodes/AssemblyIfNode"; import { SubAssemblyNode } from "@analyzer/nodes/SubAssemblyNode"; import { NewExpressionNode } from "@analyzer/nodes/NewExpressionNode"; import { TupleExpressionNode } from "@analyzer/nodes/TupleExpressionNode"; -import { TypeNameExpressionNode } from "@analyzer/nodes/TypeNameExpressionNode"; import { NameValueExpressionNode } from "@analyzer/nodes/NameValueExpressionNode"; import { NumberLiteralNode } from "@analyzer/nodes/NumberLiteralNode"; import { BooleanLiteralNode } from "@analyzer/nodes/BooleanLiteralNode"; @@ -588,18 +587,6 @@ export const find = matcher>({ documentsAnalyzer: SolFileIndexMap ) => new TupleExpressionNode(tupleExpression, uri, rootPath, documentsAnalyzer), - TypeNameExpression: async ( - typeNameExpression: astTypes.TypeNameExpression, - uri: string, - rootPath: string, - documentsAnalyzer: SolFileIndexMap - ) => - new TypeNameExpressionNode( - typeNameExpression, - uri, - rootPath, - documentsAnalyzer - ), NameValueExpression: async ( nameValueExpression: astTypes.NameValueExpression, uri: string, diff --git a/server/src/parser/analyzer/nodes/TypeNameExpressionNode.ts b/server/src/parser/analyzer/nodes/TypeNameExpressionNode.ts deleted file mode 100644 index f3696737..00000000 --- a/server/src/parser/analyzer/nodes/TypeNameExpressionNode.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - TypeNameExpression, - FinderType, - SolFileIndexMap, - Node, -} from "@common/types"; - -export class TypeNameExpressionNode extends Node { - public astNode: TypeNameExpression; - - constructor( - typeNameExpression: TypeNameExpression, - uri: string, - rootPath: string, - documentsAnalyzer: SolFileIndexMap - ) { - super(typeNameExpression, uri, rootPath, documentsAnalyzer, undefined); - this.astNode = typeNameExpression; - // TO-DO: Implement name location for rename - } - - public async accept( - find: FinderType, - orphanNodes: Node[], - parent?: Node, - expression?: Node - ): Promise { - this.setExpressionNode(expression); - // TO-DO: Method not implemented - return this; - } -} diff --git a/server/src/parser/analyzer/nodes/UsingForDeclarationNode.ts b/server/src/parser/analyzer/nodes/UsingForDeclarationNode.ts index dbd097e0..116eb0e6 100644 --- a/server/src/parser/analyzer/nodes/UsingForDeclarationNode.ts +++ b/server/src/parser/analyzer/nodes/UsingForDeclarationNode.ts @@ -21,11 +21,14 @@ export class UsingForDeclarationNode extends Node { uri, rootPath, documentsAnalyzer, - usingForDeclaration.libraryName + usingForDeclaration.libraryName ?? undefined ); this.astNode = usingForDeclaration; - if (usingForDeclaration.loc && usingForDeclaration.libraryName) { + if ( + usingForDeclaration.loc && + usingForDeclaration.libraryName !== undefined + ) { this.nameLoc = { start: { line: usingForDeclaration.loc.start.line, diff --git a/server/src/parser/common/types/index.ts b/server/src/parser/common/types/index.ts index 681a19d4..b643e638 100644 --- a/server/src/parser/common/types/index.ts +++ b/server/src/parser/common/types/index.ts @@ -68,7 +68,6 @@ import type { ThrowStatement, TryStatement, TupleExpression, - TypeNameExpression, UnaryOperation, UncheckedStatement, UserDefinedTypeName, @@ -175,7 +174,6 @@ export { ThrowStatement, TryStatement, TupleExpression, - TypeNameExpression, UnaryOperation, UncheckedStatement, UserDefinedTypeName, @@ -248,8 +246,6 @@ export interface Searcher { * @param uri Path to the file. Uri needs to be decoded and without the "file://" prefix. * @param position Position in the file. * @param from From which Node do we start searching. - * @param returnDefinitionNode If it is true, we will return the definition Node of found Node, - * otherwise we will return found Node. Default is true. * @param searchInExpression If it is true, we will also look at the expressionNode for Node * otherwise, we won't. Default is false. * @returns Founded Node. @@ -902,7 +898,6 @@ export const expressionNodeTypes = [ "NumberLiteral", "Identifier", "TupleExpression", - "TypeNameExpression", ]; /** diff --git a/server/src/services/completion/natspec.ts b/server/src/services/completion/natspec.ts new file mode 100644 index 00000000..42311472 --- /dev/null +++ b/server/src/services/completion/natspec.ts @@ -0,0 +1,267 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { + ContractDefinition, + EventDefinition, + FunctionDefinition, + StateVariableDeclaration, +} from "@solidity-parser/parser/src/ast-types"; +import * as parser from "@solidity-parser/parser"; +import { + CompletionContext, + CompletionItem, + InsertTextFormat, + Position, +} from "vscode-languageserver-protocol"; +import { + ISolFileEntry, + TextDocument, + VSCodePosition, +} from "../../parser/common/types"; + +enum NatspecStyle { + "SINGLE_LINE", + "MULTI_LINE", +} + +export const getNatspecCompletion = ( + documentAnalyzer: ISolFileEntry, + document: TextDocument, + position: VSCodePosition +) => { + // Check that the current line has the natspec string + const multiLineSearchstring = "/** */"; + const singleLineSearchstring = "///"; + + const lineText = document.getText({ + start: { line: position.line, character: 0 }, + end: { line: position.line + 1, character: 0 }, + }); + + let style: NatspecStyle; + + if (lineText.includes(multiLineSearchstring)) { + style = NatspecStyle.MULTI_LINE; + } else if (lineText.includes(singleLineSearchstring)) { + style = NatspecStyle.SINGLE_LINE; + } else { + return null; + } + + // Find the first node definition that allows natspec + const currentOffset = document.offsetAt(position); + let closestNode: + | FunctionDefinition + | ContractDefinition + | EventDefinition + | StateVariableDeclaration + | undefined; + + const storeAsClosest = ( + node: + | FunctionDefinition + | ContractDefinition + | StateVariableDeclaration + | EventDefinition + ) => { + if (!node.range || node.range[0] < currentOffset) { + return; + } + if (closestNode === undefined || node.range[0] < closestNode.range![0]) { + closestNode = node; + } + }; + parser.visit(documentAnalyzer.analyzerTree.tree.astNode, { + FunctionDefinition: storeAsClosest, + ContractDefinition: storeAsClosest, + StateVariableDeclaration: storeAsClosest, + EventDefinition: storeAsClosest, + }); + + if (closestNode === undefined) { + return null; + } + + const items: CompletionItem[] = []; + const range = { + start: position, + end: position, + }; + + // Generate natspec completion depending on node type + switch (closestNode.type) { + case "ContractDefinition": + items.push(buildContractCompletion(closestNode, range, style)); + break; + case "FunctionDefinition": + items.push(buildFunctionCompletion(closestNode, range, style)); + break; + case "StateVariableDeclaration": + items.push(buildStateVariableCompletion(closestNode, range, style)); + break; + case "EventDefinition": + items.push(buildEventCompletion(closestNode, range, style)); + break; + } + + return { + isIncomplete: false, + items, + }; +}; + +export const isNatspecTrigger = ( + context: CompletionContext | undefined, + document: TextDocument, + position: Position +) => { + const leadingText = document.getText({ + start: { line: position.line, character: position.character - 3 }, + end: { line: position.line, character: position.character }, + }); + + return context?.triggerCharacter === "*" || leadingText === "///"; +}; + +function buildContractCompletion( + _node: ContractDefinition, + range: { + start: VSCodePosition; + end: VSCodePosition; + }, + style: NatspecStyle +) { + let text = ""; + if (style === NatspecStyle.MULTI_LINE) { + text += "\n * @title $1\n"; + text += " * @author $2\n"; + text += " * @notice $3\n"; + } else if (style === NatspecStyle.SINGLE_LINE) { + text += " @title $1\n"; + text += "/// @author $2\n"; + text += "/// @notice $3"; + } + + return { + label: "NatSpec contract documentation", + textEdit: { + range, + newText: text, + }, + insertTextFormat: InsertTextFormat.Snippet, + }; +} + +function buildEventCompletion( + node: EventDefinition, + range: { start: VSCodePosition; end: VSCodePosition }, + style: NatspecStyle +) { + let text = ""; + let tabIndex = 1; + + if (style === NatspecStyle.MULTI_LINE) { + text += "\n * $0\n"; + + for (const param of node.parameters) { + text += ` * @param ${param.name} $\{${tabIndex++}}\n`; + } + } else if (style === NatspecStyle.SINGLE_LINE) { + text += " $0"; + + for (const param of node.parameters) { + text += `\n/// @param ${param.name} $\{${tabIndex++}}`; + } + } + + return { + label: "NatSpec event documentation", + textEdit: { + range, + newText: text, + }, + insertTextFormat: InsertTextFormat.Snippet, + }; +} + +function buildStateVariableCompletion( + node: StateVariableDeclaration, + range: { + start: VSCodePosition; + end: VSCodePosition; + }, + style: NatspecStyle +) { + let text = ""; + if (style === NatspecStyle.MULTI_LINE) { + if (node.variables[0].visibility === "public") { + text = `\n * @notice $\{0}\n`; + } else { + text = `\n * @dev $\{0}\n`; + } + } else if (style === NatspecStyle.SINGLE_LINE) { + if (node.variables[0].visibility === "public") { + text = ` @notice $\{0}`; + } else { + text = ` @dev $\{0}`; + } + } + + return { + label: "NatSpec variable documentation", + textEdit: { + range, + newText: text, + }, + insertTextFormat: InsertTextFormat.Snippet, + }; +} + +function buildFunctionCompletion( + node: FunctionDefinition, + range: { start: VSCodePosition; end: VSCodePosition }, + style: NatspecStyle +) { + const isMultiLine = style === NatspecStyle.MULTI_LINE; + const prefix = isMultiLine ? " *" : "///"; + const linesToAdd = []; + + // Include @notice only on public or external functions + + linesToAdd.push(`$0`); + + let tabIndex = 1; + for (const param of node.parameters) { + linesToAdd.push(`@param ${param.name} $\{${tabIndex++}}`); + } + + if ((node.returnParameters ?? []).length >= 2) { + for (const param of node.returnParameters ?? []) { + linesToAdd.push( + `@return ${ + typeof param.name === "string" ? `${param.name} ` : "" + }$\{${tabIndex++}}` + ); + } + } + + let text = isMultiLine ? "\n" : ""; + + text += linesToAdd + .map((line, index) => + index !== 0 || isMultiLine ? `${prefix} ${line}` : ` ${line}` + ) + .join("\n"); + + if (isMultiLine) { + text += "\n"; + } + + return { + label: "NatSpec function documentation", + textEdit: { + range, + newText: text, + }, + insertTextFormat: InsertTextFormat.Snippet, + }; +} diff --git a/server/src/services/completion/onCompletion.ts b/server/src/services/completion/onCompletion.ts index b675f8ac..717aea80 100644 --- a/server/src/services/completion/onCompletion.ts +++ b/server/src/services/completion/onCompletion.ts @@ -1,3 +1,5 @@ +/* eslint-disable no-template-curly-in-string */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ import { VSCodePosition, CompletionList, @@ -16,7 +18,6 @@ import { MemberAccessNode, } from "@common/types"; import { getParserPositionFromVSCodePosition } from "@common/utils"; -import { Logger } from "@utils/Logger"; import { isImportDirectiveNode } from "@analyzer/utils/typeGuards"; import { CompletionContext, @@ -29,6 +30,7 @@ import { ProjectContext } from "./types"; import { getImportPathCompletion } from "./getImportPathCompletion"; import { globalVariables, defaultCompletion } from "./defaultCompletion"; import { arrayCompletions } from "./arrayCompletions"; +import { getNatspecCompletion, isNatspecTrigger } from "./natspec"; export const onCompletion = (serverState: ServerState) => { return async (params: CompletionParams): Promise => { @@ -67,7 +69,8 @@ export const onCompletion = (serverState: ServerState) => { params.position, params.context, projCtx, - logger + serverState, + document ); return { status: "ok", result: completions }; @@ -107,8 +110,13 @@ export function doComplete( position: VSCodePosition, context: CompletionContext | undefined, projCtx: ProjectContext, - logger: Logger + { logger }: ServerState, + document: TextDocument ): CompletionList | null { + if (isNatspecTrigger(context, document, position)) { + return getNatspecCompletion(documentAnalyzer, document, position); + } + const result: CompletionList = { isIncomplete: false, items: [] }; let definitionNode = documentAnalyzer.searcher.findNodeByPosition( diff --git a/server/src/services/initialization/onInitialize.ts b/server/src/services/initialization/onInitialize.ts index 27a0b269..39eeacc1 100644 --- a/server/src/services/initialization/onInitialize.ts +++ b/server/src/services/initialization/onInitialize.ts @@ -67,7 +67,7 @@ export const onInitialize = (serverState: ServerState) => { textDocumentSync: TextDocumentSyncKind.Incremental, // Tell the client that this server supports code completion. completionProvider: { - triggerCharacters: [".", "/", '"', "'"], + triggerCharacters: [".", "/", '"', "'", "*"], }, signatureHelpProvider: { triggerCharacters: ["(", ","], @@ -135,7 +135,7 @@ function updateServerStateFromParams( serverState.hasWorkspaceFolderCapability = params.capabilities.workspace !== undefined && - params.capabilities.workspace.workspaceFolders !== undefined; + params.capabilities.workspace.workspaceFolders === true; } function logInitializationInfo( diff --git a/server/test/server.ts b/server/test/server.ts index 0896783e..76ed59e0 100644 --- a/server/test/server.ts +++ b/server/test/server.ts @@ -42,7 +42,7 @@ describe("Solidity Language Server", () => { describe("completions", () => { it("advertises capability", () => assert.deepStrictEqual(capabilities.completionProvider, { - triggerCharacters: [".", "/", '"', "'"], + triggerCharacters: [".", "/", '"', "'", "*"], })); it("registers onCompletion", () => diff --git a/server/yarn.lock b/server/yarn.lock index bda1a01a..7108be96 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -874,13 +874,6 @@ resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5" integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ== -"@solidity-parser/parser@^0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@solidity-parser/parser/-/parser-0.14.0.tgz#d51f074efb0acce0e953ec48133561ed710cebc0" - integrity sha512-cX0JJRcmPtNUJpzD2K7FdA7qQsTOk1UZnFx2k7qAg9ZRvuaH5NBe5IEdBMXGlmf2+FmjhqbygJ26H8l2SV7aKQ== - dependencies: - antlr4ts "^0.5.0-alpha.4" - "@solidity-parser/parser@^0.14.5": version "0.14.5" resolved "https://registry.yarnpkg.com/@solidity-parser/parser/-/parser-0.14.5.tgz#87bc3cc7b068e08195c219c91cd8ddff5ef1a804" diff --git a/test/protocol/projects/hardhat/contracts/completion/Natspec.sol b/test/protocol/projects/hardhat/contracts/completion/Natspec.sol new file mode 100644 index 00000000..0ef41dca --- /dev/null +++ b/test/protocol/projects/hardhat/contracts/completion/Natspec.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.8; + +/** */ +library MyLib { + +} + +/** */ +interface MyInterface { + +} + +/** */ +contract Calc { + /** */ + function has2Returns(uint a, uint b) public pure returns (uint160 retVal, uint160) { + return (1, 2); + } + + /** */ + function has1Return(uint a, uint b) public pure returns (uint160) { + return uint160(a - b); + } + + /** */ + function log(uint a) public pure { + a; + } +} + +contract MyContract { + /** */ + uint public publicCounter; + + /** */ + uint privateCounter; + + /** */ + event MyEvent(uint a, uint b); +} diff --git a/test/protocol/projects/hardhat/contracts/completion/NatspecSingle.sol b/test/protocol/projects/hardhat/contracts/completion/NatspecSingle.sol new file mode 100644 index 00000000..32023329 --- /dev/null +++ b/test/protocol/projects/hardhat/contracts/completion/NatspecSingle.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.8; + +/// +contract Calc { + /// + function sum(uint a, uint b) public pure returns (uint160 retVal, uint160) { + return (1, 2); + } +} + +contract MyContract { + /// + uint public publicCounter; + + /// + event MyEvent(uint a, uint b); +} diff --git a/test/protocol/test/initialize/data/initializeResult.json b/test/protocol/test/initialize/data/initializeResult.json index 8c0c5f57..3be23692 100644 --- a/test/protocol/test/initialize/data/initializeResult.json +++ b/test/protocol/test/initialize/data/initializeResult.json @@ -5,7 +5,7 @@ "capabilities": { "textDocumentSync": 2, "completionProvider": { - "triggerCharacters": [".", "/", "\"", "'"] + "triggerCharacters": [".", "/", "\"", "'", "*"] }, "signatureHelpProvider": { "triggerCharacters": ["(", ","] diff --git a/test/protocol/test/textDocument/completion/hardhat/completion.test.ts b/test/protocol/test/textDocument/completion/hardhat/completion.test.ts index c5d000e2..955e98d6 100644 --- a/test/protocol/test/textDocument/completion/hardhat/completion.test.ts +++ b/test/protocol/test/textDocument/completion/hardhat/completion.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-template-curly-in-string */ import { expect } from 'chai' import { test } from 'mocha' import { CompletionTriggerKind } from 'vscode-languageserver-protocol' @@ -44,6 +45,38 @@ describe('[hardhat][completion]', () => { title: '', }, }, + { + label: './Natspec.sol', + insertText: './Natspec.sol', + kind: 17, + documentation: 'Imports the package', + command: { + command: 'solidity.insertSemicolon', + arguments: [ + { + line: 0, + character: 8, + }, + ], + title: '', + }, + }, + { + label: './NatspecSingle.sol', + insertText: './NatspecSingle.sol', + kind: 17, + documentation: 'Imports the package', + command: { + command: 'solidity.insertSemicolon', + arguments: [ + { + line: 0, + character: 8, + }, + ], + title: '', + }, + }, { label: 'hardhat', textEdit: { @@ -328,4 +361,473 @@ describe('[hardhat][completion]', () => { }) }) }) + + describe('natspec', function () { + describe('multi line', function () { + describe('function natspec', function () { + test('natspec completion on function with 2 return values, 1 named 1 unnamed', async () => { + const documentPath = getProjectPath('hardhat/contracts/completion/Natspec.sol') + const documentUri = toUri(documentPath) + await client.openDocument(documentPath) + + const completions = await client.getCompletions( + documentUri, + 15, + 5, + CompletionTriggerKind.TriggerCharacter, + '*' + ) + + expect(completions).to.deep.equal({ + isIncomplete: false, + items: [ + { + label: 'NatSpec function documentation', + textEdit: { + range: { + start: { + line: 15, + character: 5, + }, + end: { + line: 15, + character: 5, + }, + }, + newText: '\n * $0\n * @param a ${1}\n * @param b ${2}\n * @return retVal ${3}\n * @return ${4}\n', + }, + insertTextFormat: 2, + }, + ], + }) + }) + + test('natspec completion on function with 1 return value', async () => { + const documentPath = getProjectPath('hardhat/contracts/completion/Natspec.sol') + const documentUri = toUri(documentPath) + await client.openDocument(documentPath) + + const completions = await client.getCompletions( + documentUri, + 20, + 5, + CompletionTriggerKind.TriggerCharacter, + '*' + ) + + expect(completions).to.deep.equal({ + isIncomplete: false, + items: [ + { + label: 'NatSpec function documentation', + textEdit: { + range: { + start: { + line: 20, + character: 5, + }, + end: { + line: 20, + character: 5, + }, + }, + newText: '\n * $0\n * @param a ${1}\n * @param b ${2}\n', + }, + insertTextFormat: 2, + }, + ], + }) + }) + + test('natspec completion on function without return value', async () => { + const documentPath = getProjectPath('hardhat/contracts/completion/Natspec.sol') + const documentUri = toUri(documentPath) + await client.openDocument(documentPath) + + const completions = await client.getCompletions( + documentUri, + 25, + 5, + CompletionTriggerKind.TriggerCharacter, + '*' + ) + + expect(completions).to.deep.equal({ + isIncomplete: false, + items: [ + { + label: 'NatSpec function documentation', + textEdit: { + range: { + start: { + line: 25, + character: 5, + }, + end: { + line: 25, + character: 5, + }, + }, + newText: '\n * $0\n * @param a ${1}\n', + }, + insertTextFormat: 2, + }, + ], + }) + }) + }) + + describe('contract/library/interface natspec', function () { + test('natspec completion for contract', async () => { + const documentPath = getProjectPath('hardhat/contracts/completion/Natspec.sol') + const documentUri = toUri(documentPath) + await client.openDocument(documentPath) + + const completions = await client.getCompletions( + documentUri, + 13, + 3, + CompletionTriggerKind.TriggerCharacter, + '*' + ) + + expect(completions).to.deep.equal({ + isIncomplete: false, + items: [ + { + label: 'NatSpec contract documentation', + textEdit: { + range: { + start: { + line: 13, + character: 3, + }, + end: { + line: 13, + character: 3, + }, + }, + newText: '\n * @title $1\n * @author $2\n * @notice $3\n', + }, + insertTextFormat: 2, + }, + ], + }) + }) + test('natspec completion for library', async () => { + const documentPath = getProjectPath('hardhat/contracts/completion/Natspec.sol') + const documentUri = toUri(documentPath) + await client.openDocument(documentPath) + + const completions = await client.getCompletions( + documentUri, + 3, + 3, + CompletionTriggerKind.TriggerCharacter, + '*' + ) + + expect(completions).to.deep.equal({ + isIncomplete: false, + items: [ + { + label: 'NatSpec contract documentation', + textEdit: { + range: { + start: { + line: 3, + character: 3, + }, + end: { + line: 3, + character: 3, + }, + }, + newText: '\n * @title $1\n * @author $2\n * @notice $3\n', + }, + insertTextFormat: 2, + }, + ], + }) + }) + test('natspec completion for interface', async () => { + const documentPath = getProjectPath('hardhat/contracts/completion/Natspec.sol') + const documentUri = toUri(documentPath) + await client.openDocument(documentPath) + + const completions = await client.getCompletions( + documentUri, + 8, + 3, + CompletionTriggerKind.TriggerCharacter, + '*' + ) + + expect(completions).to.deep.equal({ + isIncomplete: false, + items: [ + { + label: 'NatSpec contract documentation', + textEdit: { + range: { + start: { + line: 8, + character: 3, + }, + end: { + line: 8, + character: 3, + }, + }, + newText: '\n * @title $1\n * @author $2\n * @notice $3\n', + }, + insertTextFormat: 2, + }, + ], + }) + }) + }) + + describe('state variable natspec', function () { + test('natspec completion on public state variable', async () => { + const documentPath = getProjectPath('hardhat/contracts/completion/Natspec.sol') + const documentUri = toUri(documentPath) + await client.openDocument(documentPath) + + const completions = await client.getCompletions( + documentUri, + 32, + 5, + CompletionTriggerKind.TriggerCharacter, + '*' + ) + + expect(completions).to.deep.equal({ + isIncomplete: false, + items: [ + { + label: 'NatSpec variable documentation', + textEdit: { + range: { + start: { + line: 32, + character: 5, + }, + end: { + line: 32, + character: 5, + }, + }, + newText: '\n * @notice ${0}\n', + }, + insertTextFormat: 2, + }, + ], + }) + }) + + test('natspec completion on private state variable', async () => { + const documentPath = getProjectPath('hardhat/contracts/completion/Natspec.sol') + const documentUri = toUri(documentPath) + await client.openDocument(documentPath) + + const completions = await client.getCompletions( + documentUri, + 35, + 5, + CompletionTriggerKind.TriggerCharacter, + '*' + ) + + expect(completions).to.deep.equal({ + isIncomplete: false, + items: [ + { + label: 'NatSpec variable documentation', + textEdit: { + range: { + start: { + line: 35, + character: 5, + }, + end: { + line: 35, + character: 5, + }, + }, + newText: '\n * @dev ${0}\n', + }, + insertTextFormat: 2, + }, + ], + }) + }) + }) + + test('natspec completion on event', async () => { + const documentPath = getProjectPath('hardhat/contracts/completion/Natspec.sol') + const documentUri = toUri(documentPath) + await client.openDocument(documentPath) + + const completions = await client.getCompletions(documentUri, 38, 5, CompletionTriggerKind.TriggerCharacter, '*') + + expect(completions).to.deep.equal({ + isIncomplete: false, + items: [ + { + label: 'NatSpec event documentation', + textEdit: { + range: { + start: { + line: 38, + character: 5, + }, + end: { + line: 38, + character: 5, + }, + }, + newText: '\n * $0\n * @param a ${1}\n * @param b ${2}\n', + }, + insertTextFormat: 2, + }, + ], + }) + }) + }) + + describe('single line', function () { + describe('function natspec', function () { + test('natspec completion on function with 2 return values', async () => { + const documentPath = getProjectPath('hardhat/contracts/completion/NatspecSingle.sol') + const documentUri = toUri(documentPath) + await client.openDocument(documentPath) + + const completions = await client.getCompletions( + documentUri, + 5, + 5, + CompletionTriggerKind.TriggerCharacter, + '/' + ) + + expect(completions).to.deep.equal({ + isIncomplete: false, + items: [ + { + label: 'NatSpec function documentation', + textEdit: { + range: { + start: { + line: 5, + character: 5, + }, + end: { + line: 5, + character: 5, + }, + }, + newText: ' $0\n/// @param a ${1}\n/// @param b ${2}\n/// @return retVal ${3}\n/// @return ${4}', + }, + insertTextFormat: 2, + }, + ], + }) + }) + }) + + test('natspec completion for contract', async () => { + const documentPath = getProjectPath('hardhat/contracts/completion/NatspecSingle.sol') + const documentUri = toUri(documentPath) + await client.openDocument(documentPath) + + const completions = await client.getCompletions(documentUri, 3, 3, CompletionTriggerKind.TriggerCharacter, '/') + + expect(completions).to.deep.equal({ + isIncomplete: false, + items: [ + { + label: 'NatSpec contract documentation', + textEdit: { + range: { + start: { + line: 3, + character: 3, + }, + end: { + line: 3, + character: 3, + }, + }, + newText: ' @title $1\n/// @author $2\n/// @notice $3', + }, + insertTextFormat: 2, + }, + ], + }) + }) + + test('natspec completion on event', async () => { + const documentPath = getProjectPath('hardhat/contracts/completion/NatspecSingle.sol') + const documentUri = toUri(documentPath) + await client.openDocument(documentPath) + + const completions = await client.getCompletions(documentUri, 15, 5, CompletionTriggerKind.TriggerCharacter, '/') + + expect(completions).to.deep.equal({ + isIncomplete: false, + items: [ + { + label: 'NatSpec event documentation', + textEdit: { + range: { + start: { + line: 15, + character: 5, + }, + end: { + line: 15, + character: 5, + }, + }, + newText: ' $0\n/// @param a ${1}\n/// @param b ${2}', + }, + insertTextFormat: 2, + }, + ], + }) + }) + + test('natspec completion on public state variable', async () => { + const documentPath = getProjectPath('hardhat/contracts/completion/NatspecSingle.sol') + const documentUri = toUri(documentPath) + await client.openDocument(documentPath) + + const completions = await client.getCompletions(documentUri, 12, 5, CompletionTriggerKind.TriggerCharacter, '/') + + expect(completions).to.deep.equal({ + isIncomplete: false, + items: [ + { + label: 'NatSpec variable documentation', + textEdit: { + range: { + start: { + line: 12, + character: 5, + }, + end: { + line: 12, + character: 5, + }, + }, + newText: ' @notice ${0}', + }, + insertTextFormat: 2, + }, + ], + }) + }) + }) + }) })