Skip to content

Commit

Permalink
Natspec completions (#359)
Browse files Browse the repository at this point in the history
* feat: automatic natspec documentation

* tests for natspec completion

* add natspec completion gif to readme

* add tab-jumping and return param name

* feat: natspec completion for contracts, interfaces and libraries

* feat: natspec completion for state vars and events

* feat: natspec triple slash completion

* fix: update logic on natspec generation

* fix: show either @notice or @dev on state variables
  • Loading branch information
antico5 committed Jan 23, 2023
1 parent 8e8ff53 commit e453a48
Show file tree
Hide file tree
Showing 12 changed files with 848 additions and 8 deletions.
6 changes: 6 additions & 0 deletions EXTENSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file added docs/gifs/natspec-contract.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/gifs/natspec-function.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 0 additions & 2 deletions server/src/parser/common/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,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.
Expand Down
267 changes: 267 additions & 0 deletions server/src/services/completion/natspec.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
14 changes: 11 additions & 3 deletions server/src/services/completion/onCompletion.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/* eslint-disable no-template-curly-in-string */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {
VSCodePosition,
CompletionList,
Expand All @@ -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,
Expand All @@ -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<CompletionList | null> => {
Expand Down Expand Up @@ -67,7 +69,8 @@ export const onCompletion = (serverState: ServerState) => {
params.position,
params.context,
projCtx,
logger
serverState,
document
);

return { status: "ok", result: completions };
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion server/src/services/initialization/onInitialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: ["(", ","],
Expand Down
2 changes: 1 addition & 1 deletion server/test/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ describe("Solidity Language Server", () => {
describe("completions", () => {
it("advertises capability", () =>
assert.deepStrictEqual(capabilities.completionProvider, {
triggerCharacters: [".", "/", '"', "'"],
triggerCharacters: [".", "/", '"', "'", "*"],
}));

it("registers onCompletion", () =>
Expand Down
41 changes: 41 additions & 0 deletions test/protocol/projects/hardhat/contracts/completion/Natspec.sol
Original file line number Diff line number Diff line change
@@ -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);
}
Loading

0 comments on commit e453a48

Please sign in to comment.