diff --git a/scripts/tests.sh b/scripts/tests.sh index 5515a6869..80559f5e2 100755 --- a/scripts/tests.sh +++ b/scripts/tests.sh @@ -84,7 +84,7 @@ printTask "Testing Python scripts..." "$REPO_ROOT/test/pyscriptTests.py" printTask "Testing LSP..." -"$REPO_ROOT/scripts/test_solidity_lsp.py" "${SOLIDITY_BUILD_DIR}/solc/solc" +"$REPO_ROOT/test/lsp.py" "${SOLIDITY_BUILD_DIR}/solc/solc" printTask "Running commandline tests..." # Only run in parallel if this is run on CI infrastructure diff --git a/test/libsolidity/lsp/goto_definition.sol b/test/libsolidity/lsp/goto_definition.sol index 675c1297c..7415d3866 100644 --- a/test/libsolidity/lsp/goto_definition.sol +++ b/test/libsolidity/lsp/goto_definition.sol @@ -2,13 +2,16 @@ pragma solidity >=0.8.0; import "./lib.sol"; +// ^ @importDirective interface I { function f(uint x) external returns (uint); + // ^ @functionF } contract IA is I + // ^^ @IASymbol { function f(uint x) public pure override returns (uint) { return x + 1; } } @@ -21,6 +24,7 @@ contract IB is I library IntLib { function add(int self, int b) public pure returns (int) { return self + b; } + // ^^^ @IntLibAdd } contract C @@ -29,40 +33,175 @@ contract C function virtual_inheritance() public payable { obj = new IA(); + // ^ @usingIASymbol obj.f(1); // goto-definition should jump to definition of interface. + // ^ @virtualFunctionLookup } using IntLib for *; function using_for(int i) pure public { i.add(5); + // ^ @usingIntAdd 14.add(4); } function useLib(uint n) public payable returns (uint) { return Lib.add(n, 1); + // ^ @LibSymbol + // ^ @LibAddSymbol } function enums(Color c) public pure returns (Color d) + // ^ @ColorSymbolInParameter { Color e = Color.Red; + // ^ @eVariableDeclaration + // ^ @RedEnumMemberAccess if (c == e) + // ^ @eVariableAccess d = Color.Green; else d = c; } type Price is uint128; + // ^^^^^ @PriceDeclaration function udlTest() public pure returns (uint128) { Price p = Price.wrap(128); + // ^ @PriceSymbol + // ^ @PriceInWrap return Price.unwrap(p); } function structCtorTest(uint8 v) public pure returns (uint8 result) { RGBColor memory c = RGBColor(v, 2 * v, 3 * v); + // ^ @RGBColorCursor result = c.red; + int a; +// ^^^^^ @unusedLocalVar } } +// ---- +// goto_definition: @unusedLocalVar 2072 +// lib: @diagnostics 2072 +// -> textDocument/definition { +// "position": @importDirective +// } +// <- [ +// { +// "range": { +// "end": { +// "character": 0, +// "line": 0 +// }, +// "start": { +// "character": 0, +// "line": 0 +// } +// }, +// "uri": "lib.sol" +// } +// ] +// -> textDocument/definition { +// "position": @usingIASymbol +// } +// <- [ +// { +// "range": @IASymbol, +// "uri": "goto_definition.sol" +// } +// ] +// -> textDocument/definition { +// "position": @virtualFunctionLookup +// } +// <- [ +// { +// "range": @functionF, +// "uri": "goto_definition.sol" +// } +// ] +// -> textDocument/definition { +// "position": @usingIntAdd +// } +// <- [ +// { +// "range": @IntLibAdd, +// "uri": "goto_definition.sol" +// } +// ] +// -> textDocument/definition { +// "position": @LibSymbol +// } +// <- [ +// { +// "range": @LibLibrary, +// "uri": "lib.sol" +// } +// ] +// -> textDocument/definition { +// "position": @LibAddSymbol +// } +// <- [ +// { +// "range": @addSymbol, +// "uri": "lib.sol" +// } +// ] +// -> textDocument/definition { +// "position": @ColorSymbolInParameter +// } +// <- [ +// { +// "range": @ColorEnum, +// "uri": "lib.sol" +// } +// ] +// -> textDocument/definition { +// "position": @RedEnumMemberAccess +// } +// <- [ +// { +// "range": @EnumMemberRed, +// "uri": "lib.sol" +// } +// ] +// -> textDocument/definition { +// "position": @eVariableAccess +// } +// <- [ +// { +// "range": @eVariableDeclaration, +// "uri": "goto_definition.sol" +// } +// ] +// -> textDocument/definition { +// "position": @PriceSymbol +// } +// <- [ +// { +// "range": @PriceDeclaration, +// "uri": "goto_definition.sol" +// } +// ] +// -> textDocument/definition { +// "position": @PriceInWrap +// } +// <- [ +// { +// "range": @PriceDeclaration, +// "uri": "goto_definition.sol" +// } +// ] +// -> textDocument/definition { +// "position": @RGBColorCursor +// } +// <- [ +// { +// "range": @RGBColorStruct, +// "uri": "lib.sol" +// } +// ] diff --git a/test/libsolidity/lsp/goto_definition_imports.sol b/test/libsolidity/lsp/goto_definition_imports.sol index b3df921fe..dd0267d71 100644 --- a/test/libsolidity/lsp/goto_definition_imports.sol +++ b/test/libsolidity/lsp/goto_definition_imports.sol @@ -2,18 +2,70 @@ pragma solidity >=0.8.0; import {Weather as Wetter} from "./lib.sol"; +// ^ @wheatherImportCursor import "./lib.sol" as That; +// ^^^^ @ThatImport contract C { function test_symbol_alias() public pure returns (Wetter result) + // ^ @WetterCursor { result = Wetter.Sunny; } function test_library_alias() public pure returns (That.Color result) + // ^ @ThatCursor { That.Color color = That.Color.Red; +// ^ @ThatVarCursor ^ @ThatExpressionCursor result = color; } } +// ---- +// lib: @diagnostics 2072 +// -> textDocument/definition { +// "position": @wheatherImportCursor +// } +// <- [ +// { +// "range": @whetherEnum, +// "uri": "lib.sol" +// } +// ] +// -> textDocument/definition { +// "position": @WetterCursor +// } +// <- [ +// { +// "range": @whetherEnum, +// "uri": "lib.sol" +// } +// ] +// -> textDocument/definition { +// "position": @ThatCursor +// } +// <- [ +// { +// "range": @ColorEnum, +// "uri": "lib.sol" +// } +// ] +// -> textDocument/definition { +// "position": @ThatVarCursor +// } +// <- [ +// { +// "range": @ColorEnum, +// "uri": "lib.sol" +// } +// ] +// -> textDocument/definition { +// "position": @ThatExpressionCursor +// } +// <- [ +// { +// "range": @ThatImport, +// "uri": "goto_definition_imports.sol" +// } +// ] diff --git a/test/libsolidity/lsp/lib.sol b/test/libsolidity/lsp/lib.sol index 031cf19ad..c476f2dc2 100644 --- a/test/libsolidity/lsp/lib.sol +++ b/test/libsolidity/lsp/lib.sol @@ -5,6 +5,7 @@ pragma solidity >=0.8.0; error E(uint, uint); enum Weather { +// ^^^^^^^ @whetherEnum Sunny, Cloudy, Rainy @@ -12,8 +13,10 @@ enum Weather { /// Some custom Color enum type holding 3 colors. enum Color { +// ^^^^^ @ColorEnum /// Red color. Red, +// ^^^ @EnumMemberRed /// Green color. Green, /// Blue color. @@ -21,9 +24,11 @@ enum Color { } library Lib +// @ ^^^ @LibLibrary { function add(uint a, uint b) public pure returns (uint result) // ^( @addFunction +// ^^^ @addSymbol { result = a + b; } @@ -37,8 +42,11 @@ library Lib } struct RGBColor +// ^^^^^^^^ @RGBColorStruct { uint8 red; uint8 green; uint8 blue; } +// ---- +// lib: @diagnostics 2072 diff --git a/test/libsolidity/lsp/publish_diagnostics_1.sol b/test/libsolidity/lsp/publish_diagnostics_1.sol index be15a4090..357de7a35 100644 --- a/test/libsolidity/lsp/publish_diagnostics_1.sol +++ b/test/libsolidity/lsp/publish_diagnostics_1.sol @@ -19,3 +19,5 @@ contract D // ^^^^^^^^^^^^ @unusedContractVariable } } +// ---- +// publish_diagnostics_1: @unusedReturnVariable 6321 @unusedVariable 2072 @unusedContractVariable 2072 diff --git a/test/libsolidity/lsp/publish_diagnostics_2.sol b/test/libsolidity/lsp/publish_diagnostics_2.sol index 65b4df585..cf052e7c9 100644 --- a/test/libsolidity/lsp/publish_diagnostics_2.sol +++ b/test/libsolidity/lsp/publish_diagnostics_2.sol @@ -22,3 +22,5 @@ contract D // ^^^^^^^^^^^^^^^^^^^^^ @wrongArgumentsCount } } +// ---- +// publish_diagnostics_2: @conversionError 9574 @argumentsRequired 6777 @wrongArgumentsCount 6160 diff --git a/test/lsp.py b/test/lsp.py index 8b7398cf2..79be59f8e 100755 --- a/test/lsp.py +++ b/test/lsp.py @@ -8,13 +8,93 @@ import subprocess import sys import traceback import re - +import tty +import functools +from collections import namedtuple +from copy import deepcopy from typing import Any, List, Optional, Tuple, Union +from itertools import islice + from enum import Enum, auto import colorama # Enables the use of SGR & CUP terminal VT sequences on Windows. from deepdiff import DeepDiff +""" +Named tuple that holds various regexes used to parse the test specification. +""" +TestRegexesTuple = namedtuple("TestRegexesTuple", [ + "sendRequest", # regex to find requests to be sent & tested + "findQuotedTag", # regex to find tags wrapped in quotes + "findTag", # regex to find tags + "fileDiagnostics", # regex to find diagnostic expectations for a file + "diagnostic" # regex to find a single diagnostic within the file expectations +]) +""" +Instance of the named tuple holding the regexes +""" +TEST_REGEXES = TestRegexesTuple( + re.compile(R'^// -> (?P[\w\/]+) {'), + re.compile(R'(?P"@\w+")'), + re.compile(R'(?P@\w+)'), + re.compile(R'// (?P\w+):[ ]?(?P[\w @]*)'), + re.compile(R'(?P@\w+) (?P\d\d\d\d)') +) + +""" +Named tuple holding regexes to find tags in the solidity code +""" +TagRegexesTuple = namedtuple("TagRegexestuple", ["simpleRange", "multilineRange"]) +TAG_REGEXES = TagRegexesTuple( + re.compile(R"(?P[\^]+) (?P@\w+)"), + re.compile(R"\^(?P[()]{1,2}) (?P@\w+)$") +) + + +def count_index(lines, start=0): + """ + Takes an iterable of lines and adds the current byte index so it's available + when iterating or looping. + """ + n = start + for elem in lines: + yield n, elem + n += 1 + len(elem) + +def tags_only(lines, start=0): + """ + Filter the lines for tag comments and report line number that tags refer to. + """ + n = start + numCommentLines = 0 + + def hasTag(line): + if line.find("// ") != -1: + for _, regex in TAG_REGEXES._asdict().items(): + if regex.search(line[len("// "):]) is not None: + return True + return False + + for line in lines: + if hasTag(line): + numCommentLines += 1 + yield n - numCommentLines, line + else: + numCommentLines = 0 + + n += 1 + + +def prepend_comments(sequence): + """ + Prepends a comment indicator to each element + """ + result = "" + for line in sequence.splitlines(True): + result = result + "// " + line + return result + + # {{{ JsonRpcProcess class BadHeader(Exception): def __init__(self, msg: str): @@ -59,7 +139,7 @@ class JsonRpcProcess: while True: # read header line = self.process.stdout.readline() - if line == '': + if len(line) == 0: # server quit return None line = line.decode("utf-8") @@ -118,12 +198,30 @@ SGR_STATUS_OKAY = '\033[1;32m' SGR_STATUS_FAIL = '\033[1;31m' class ExpectationFailed(Exception): - def __init__(self, actual, expected): - self.actual = json.dumps(actual, sort_keys=True) - self.expected = json.dumps(expected, sort_keys=True) - diff = json.dumps(DeepDiff(actual, expected), indent=4) + class Part(Enum): + Diagnostics = auto() + Methods = auto() + + def __init__(self, reason: str, part): + self.part = part + super().__init__(reason) + +class JSONExpectationFailed(ExpectationFailed): + def __init__(self, actual, expected, part): + self.actual = actual + self.expected = expected + + expected_pretty = "" + + if expected is not None: + expected_pretty = json.dumps(expected, sort_keys=True) + + diff = DeepDiff(actual, expected) + super().__init__( - f"\n\tExpected {self.expected}\n\tbut got {self.actual}.\n\t{diff}" + f"\n\tExpected {expected_pretty}" + \ + f"\n\tbut got {json.dumps(actual, sort_keys=True)}.\n\t{diff}", + part ) @@ -180,15 +278,407 @@ class Counter: failed: int = 0 -class Marker(Enum): - SimpleRange = auto() - MultilineRange = auto() - - # Returns the given marker with the end extended by 'amount' def extendEnd(marker, amount=1): - marker["end"]["character"] += amount - return marker + newMarker = deepcopy(marker) + newMarker["end"]["character"] += amount + return newMarker + +class TestParserException(Exception): + def __init__(self, incompleteResult, msg: str): + self.result = incompleteResult + super().__init__("Failed to parse test specification: " + msg) + +class TestParser: + """ + Parses test specifications. + Usage example: + + parsed_testcases = TestParser(content).parse() + + # First diagnostics are yielded + expected_diagnostics = next(parsed_testcases) + ... + # Now each request/response pair in the test definition + for testcase in self.parsed_testcases: + ... + """ + RequestAndResponse = namedtuple('RequestAndResponse', + "method, request, response, responseBegin, responseEnd", + defaults=(None, None, None, None) + ) + Diagnostics = namedtuple('Diagnostics', 'tests start end has_header') + Diagnostic = namedtuple('Diagnostic', 'marker code') + + TEST_START = "// ----" + + def __init__(self, content: str): + self.content = content + self.lines = None + self.current_line_tuple = None + + def parse(self): + """ + Starts parsing the test specifications. + Will first yield with the diagnostics expectations as type 'Diagnostics'. + After that, it will yield once for every Request/Response pair found in + the file, each time as type 'RequestAndResponse'. + + """ + testDefStartIdx = self.content.rfind(f"\n{self.TEST_START}\n") + + if testDefStartIdx == -1: + # Set start/end to end of file if there is no test section + yield self.Diagnostics({}, len(self.content), len(self.content), False) + return + + self.lines = islice( + count_index(self.content[testDefStartIdx+1:].splitlines(), testDefStartIdx+1), + 1, + None + ) + self.next_line() + + yield self.parseDiagnostics() + + while not self.at_end(): + yield self.RequestAndResponse(**self.parseRequestAndResponse()) + self.next_line() + + + def parseDiagnostics(self): + """ + Parse diagnostic expectations specified in the file. + Returns a named tuple instance of "Diagnostics" + """ + diagnostics = { "tests": {}, "has_header": True } + + diagnostics["start"] = self.position() + + while not self.at_end(): + fileDiagMatch = TEST_REGEXES.fileDiagnostics.match(self.current_line()) + if fileDiagMatch is None: + break + + testDiagnostics = [] + + for diagnosticMatch in TEST_REGEXES.diagnostic.finditer(fileDiagMatch.group("diagnostics")): + testDiagnostics.append(self.Diagnostic( + diagnosticMatch.group("tag"), + int(diagnosticMatch.group("code")) + )) + + diagnostics["tests"][fileDiagMatch.group("testname")] = testDiagnostics + + self.next_line() + + diagnostics["end"] = self.position() + return self.Diagnostics(**diagnostics) + + + def parseRequestAndResponse(self): + RESPONSE_START = "// <- " + REQUEST_END = "// }" + COMMENT_PREFIX = "// " + + ret = {} + start_character = None + + # Parse request header + requestResult = TEST_REGEXES.sendRequest.match(self.current_line()) + if requestResult is not None: + ret["method"] = requestResult.group("method") + ret["request"] = "{\n" + else: + raise TestParserException(ret, "Method for request not found") + + self.next_line() + + # Search for request block end + while not self.at_end(): + line = self.current_line() + ret["request"] += line[len(COMMENT_PREFIX):] + "\n" + + self.next_line() + + if line.startswith(REQUEST_END): + break + + # Reached end without finding request_end. Abort. + if self.at_end(): + raise TestParserException(ret, "Request body not found") + + + # Parse response header + if self.current_line().startswith(RESPONSE_START): + start_character = self.current_line()[len(RESPONSE_START)] + if start_character not in ("{", "["): + raise TestParserException(ret, "Response header malformed") + ret["response"] = self.current_line()[len(RESPONSE_START):] + "\n" + ret["responseBegin"] = self.position() + else: + raise TestParserException(ret, "Response header not found") + + self.next_line() + + end_character = "}" if start_character == "{" else "]" + + # Search for request block end + while not self.at_end(): + ret["response"] += self.current_line()[len(COMMENT_PREFIX):] + "\n" + + if self.current_line().startswith(f"// {end_character}"): + ret["responseEnd"] = self.position() + len(self.current_line()) + break + + self.next_line() + + # Reached end without finding block_end. Abort. + if self.at_end(): + raise TestParserException(ret, "Response footer not found") + + return ret + + def next_line(self): + self.current_line_tuple = next(self.lines, None) + + def current_line(self): + return self.current_line_tuple[1] + + def position(self): + """ + Returns current byte position + """ + if self.current_line_tuple is None: + return len(self.content) + return self.current_line_tuple[0] + + def at_end(self): + """ + Returns True if we exhausted the lines + """ + return self.current_line_tuple is None + +class FileTestRunner: + """ + Runs all tests in a given file. + It is required to call test_diagnostics() before calling test_methods(). + + When a test fails, asks the user how to proceed. + Offers automatic test expectation updates and rerunning of the tests. + """ + + class TestResult(Enum): + SuccessOrIgnored = auto() + Reparse = auto() + + def __init__(self, test_name, solc, suite): + self.test_name = test_name + self.suite = suite + self.solc = solc + self.open_tests = [] + self.content = self.suite.get_test_file_contents(self.test_name) + self.markers = self.suite.get_file_tags(self.test_name) + self.parsed_testcases = None + self.expected_diagnostics = None + + def test_diagnostics(self): + """ + Test that the expected diagnostics match the actual diagnostics + """ + try: + self.parsed_testcases = TestParser(self.content).parse() + + # Process diagnostics first + self.expected_diagnostics = next(self.parsed_testcases) + assert isinstance(self.expected_diagnostics, TestParser.Diagnostics) is True + + tests = self.expected_diagnostics.tests + + # Add our own test diagnostics if they didn't exist + if self.test_name not in tests: + tests[self.test_name] = [] + + published_diagnostics = \ + self.suite.open_file_and_wait_for_diagnostics(self.solc, self.test_name) + + for diagnostics in published_diagnostics: + self.open_tests.append(diagnostics["uri"].replace(self.suite.project_root_uri + "/", "")[:-len(".sol")]) + + self.suite.expect_equal( + len(published_diagnostics), + len(tests), + description="Amount of reports does not match!") + + for diagnostics in published_diagnostics: + testname = diagnostics["uri"].replace(self.suite.project_root_uri + "/", "")[:-len(".sol")] + + expected_diagnostics = tests[testname] + self.suite.expect_equal( + len(diagnostics["diagnostics"]), + len(expected_diagnostics), + description="Unexpected amount of diagnostics" + ) + markers = self.suite.get_file_tags(testname) + for actual_diagnostic in diagnostics["diagnostics"]: + expected_diagnostic = next((diagnostic for diagnostic in + expected_diagnostics if actual_diagnostic['range'] == + markers[diagnostic.marker]), None) + + if expected_diagnostic is None: + raise ExpectationFailed( + f"Unexpected diagnostic: {json.dumps(actual_diagnostic, indent=4, sort_keys=True)}", + ExpectationFailed.Part.Diagnostics + ) + + self.suite.expect_diagnostic( + actual_diagnostic, + code=expected_diagnostic.code, + marker=markers[expected_diagnostic.marker] + ) + + except Exception as e: + print(e) + self.close_all_open_files() + raise + + def close_all_open_files(self): + for test in self.open_tests: + self.solc.send_message( + 'textDocument/didClose', + { 'textDocument': { 'uri': self.suite.get_test_file_uri(test) }} + ) + self.suite.wait_for_diagnostics(self.solc) + + self.open_tests.clear() + + def test_methods(self) -> bool: + """ + Test all methods. Returns False if a reparsing is required, else True + """ + try: + # Now handle each request/response pair in the test definition + for testcase in self.parsed_testcases: + try: + self.run_testcase(testcase) + except JSONExpectationFailed as e: + result = self.user_interaction_failed_method_test(testcase, e.actual, e.expected) + + if result == self.TestResult.Reparse: + return False + + return True + except TestParserException as e: + print(e) + print(e.result) + raise + finally: + self.close_all_open_files() + + def user_interaction_failed_method_test(self, testcase, actual, expected): + actual_pretty = self.suite.replace_ranges_with_tags(actual) + + if expected is None: + print("Failed to parse expected response, received:\n" + actual) + else: + print("Expected:\n" + \ + self.suite.replace_ranges_with_tags(expected) + \ + "\nbut got:\n" + actual_pretty + ) + + while True: + print("(u)pdate/(r)etry/(i)gnore?") + user_response = sys.stdin.read(1) + if user_response == "i": + return self.TestResult.SuccessOrIgnored + + if user_response == "u": + actual = actual["result"] + self.content = self.content[:testcase.responseBegin] + \ + prepend_comments("<- " + self.suite.replace_ranges_with_tags(actual)) + \ + self.content[testcase.responseEnd:] + + with open(self.suite.get_test_file_path(self.test_name), mode="w", encoding="utf-8", newline='') as f: + f.write(self.content) + return self.TestResult.Reparse + if user_response == "r": + return self.TestResult.Reparse + + print("Invalid response.") + + + def run_testcase(self, testcase: TestParser.RequestAndResponse): + """ + Runs the given testcase. + """ + requestBodyJson = self.parse_json_with_tags(testcase.request, self.markers) + # add textDocument/uri if missing + if 'textDocument' not in requestBodyJson: + requestBodyJson['textDocument'] = { 'uri': self.suite.get_test_file_uri(self.test_name) } + actualResponseJson = self.solc.call_method(testcase.method, requestBodyJson) + + # simplify response + for result in actualResponseJson["result"]: + result["uri"] = result["uri"].replace(self.suite.project_root_uri + "/", "") + if "jsonrpc" in actualResponseJson: + actualResponseJson.pop("jsonrpc") + + try: + expectedResponseJson = self.parse_json_with_tags(testcase.response, self.markers) + except json.decoder.JSONDecodeError: + expectedResponseJson = None + + expectedResponseJson = { "result": expectedResponseJson } + + self.suite.expect_equal( + actualResponseJson, + expectedResponseJson, + f"Request failed: \n{testcase.request}", + ExpectationFailed.Part.Methods + ) + + + def parse_json_with_tags(self, content, markersFallback): + """ + Replaces any tags with their actual content and parsers the result as + json to return it. + """ + split_by_tag = TEST_REGEXES.findTag.split(content) + + # add quotes so we can parse it as json + contentReplaced = '"'.join(split_by_tag) + contentJson = json.loads(contentReplaced) + + def replace_tag(data, markers): + + if isinstance(data, list): + for el in data: + replace_tag(el, markers) + return data + + # Check if we need markers from a specific file + # Needs to be done before the loop or it might be called only after + # we found "range" or "position" + if "uri" in data: + markers = self.suite.get_file_tags(data["uri"][:-len(".sol")]) + + for key, val in data.items(): + if key == "range": + for tag, tagRange in markers.items(): + if tag == val: + data[key] = tagRange + elif key == "position": + for tag, tagRange in markers.items(): + if tag == val: + data[key] = tagRange["start"] + elif isinstance(val, dict): + replace_tag(val, markers) + elif isinstance(val, list): + for el in val: + replace_tag(el, markers) + return data + + return replace_tag(contentJson, markersFallback) class SolidityLSPTestSuite: # {{{ @@ -198,7 +688,6 @@ class SolidityLSPTestSuite: # {{{ trace_io: bool = False fail_fast: bool = False test_pattern: str - marker_regexes: {} def __init__(self): colorama.init() @@ -210,10 +699,6 @@ class SolidityLSPTestSuite: # {{{ self.trace_io = args.trace_io self.test_pattern = args.test_pattern self.fail_fast = args.fail_fast - self.marker_regexes = { - Marker.SimpleRange: re.compile(R"(?P[\^]+) (?P@\w+)"), - Marker.MultilineRange: re.compile(R"\^(?P[()]) (?P@\w+)$") - } print(f"{SGR_NOTICE}test pattern: {self.test_pattern}{SGR_RESET}") @@ -283,6 +768,9 @@ class SolidityLSPTestSuite: # {{{ params['rootUri'] = None lsp.call_method('initialize', params) lsp.send_notification('initialized') + # Enable traces to receive the amount of expected diagnostics before + # actually receiving them. + lsp.send_message("$/setTrace", { 'value': 'messages' }) # {{{ helpers def get_test_file_path(self, test_case_name): @@ -314,35 +802,86 @@ class SolidityLSPTestSuite: # {{{ raise RuntimeError(f"Error {code} received. {text}") if 'method' not in message.keys(): raise RuntimeError("No method received but something else.") - self.expect_equal(message['method'], method_name, "Ensure expected method name") + self.expect_equal(message['method'], method_name, description="Ensure expected method name") return message['params'] - def wait_for_diagnostics(self, solc: JsonRpcProcess, count: int) -> List[dict]: + def wait_for_diagnostics(self, solc: JsonRpcProcess) -> List[dict]: """ - Return `count` number of published diagnostic reports sorted by file URI. + Return all published diagnostic reports sorted by file URI. """ reports = [] - for _ in range(0, count): + + num_files = solc.receive_message()["params"]["openFileCount"] + + for _ in range(0, num_files): message = solc.receive_message() + assert message is not None # This can happen if the server aborts early. + reports.append( self.require_params_for_method( 'textDocument/publishDiagnostics', message, ) ) + return sorted(reports, key=lambda x: x['uri']) + def fetch_and_format_diagnostics(self, solc: JsonRpcProcess, test): + expectations = "" + + published_diagnostics = self.open_file_and_wait_for_diagnostics(solc, test) + + for diagnostics in published_diagnostics: + testname = diagnostics["uri"].replace(self.project_root_uri + "/", "")[:-len(".sol")] + + # Skip empty diagnostics within the same file + if len(diagnostics["diagnostics"]) == 0 and testname == test: + continue + + expectations += f"// {testname}:" + + for diagnostic in diagnostics["diagnostics"]: + tag = self.find_tag_with_range(testname, diagnostic['range']) + + if tag is None: + raise Exception(f"No tag found for diagnostic range {diagnostic['range']}") + + expectations += f" {tag} {diagnostic['code']}" + expectations += "\n" + + return expectations + + def update_diagnostics_in_file( + self, + solc: JsonRpcProcess, + test, + content, + current_diagnostics: TestParser.Diagnostics + ): + test_header = "" + + if not current_diagnostics.has_header: + test_header = f"{TestParser.TEST_START}\n" + + content = content[:current_diagnostics.start] + \ + test_header + \ + self.fetch_and_format_diagnostics(solc, test) + \ + content[current_diagnostics.end:] + + with open(self.get_test_file_path(test), mode="w", encoding="utf-8", newline='') as f: + f.write(content) + + return content + def open_file_and_wait_for_diagnostics( self, solc_process: JsonRpcProcess, test_case_name: str, - max_diagnostic_reports: int = 1 ) -> List[Any]: """ Opens file for given test case and waits for diagnostics to be published. """ - assert max_diagnostic_reports > 0 solc_process.send_message( 'textDocument/didOpen', { @@ -355,9 +894,15 @@ class SolidityLSPTestSuite: # {{{ } } ) - return self.wait_for_diagnostics(solc_process, max_diagnostic_reports) + return self.wait_for_diagnostics(solc_process) - def expect_equal(self, actual, expected, description="Equality") -> None: + def expect_equal( + self, + actual, + expected, + description="Equality", + part=ExpectationFailed.Part.Diagnostics + ) -> None: self.assertion_counter.total += 1 prefix = f"[{self.assertion_counter.total}] {SGR_ASSERT_BEGIN}{description}: " diff = DeepDiff(actual, expected) @@ -370,7 +915,7 @@ class SolidityLSPTestSuite: # {{{ # Failed assertions are always printed. self.assertion_counter.failed += 1 print(prefix + SGR_STATUS_FAIL + 'FAILED' + SGR_RESET) - raise ExpectationFailed(actual, expected) + raise JSONExpectationFailed(actual, expected, part) def expect_empty_diagnostics(self, published_diagnostics: List[dict]) -> None: self.expect_equal(len(published_diagnostics), 1, "one publish diagnostics notification") @@ -384,10 +929,21 @@ class SolidityLSPTestSuite: # {{{ startEndColumns: Tuple[int, int] = None, marker: {} = None ): - self.expect_equal(diagnostic['code'], code, f'diagnostic: {code}') + self.expect_equal( + diagnostic['code'], + code, + ExpectationFailed.Part.Diagnostics, + f'diagnostic: {code}' + ) if marker: - self.expect_equal(diagnostic['range'], marker, "diagnostic: check range") + self.expect_equal( + diagnostic['range'], + marker, + ExpectationFailed.Part.Diagnostics, + "diagnostic: check range" + ) + else: assert len(startEndColumns) == 2 [startColumn, endColumn] = startEndColumns @@ -397,6 +953,7 @@ class SolidityLSPTestSuite: # {{{ 'start': {'character': startColumn, 'line': lineNo}, 'end': {'character': endColumn, 'line': lineNo} }, + ExpectationFailed.Part.Diagnostics, "diagnostic: check range" ) @@ -446,44 +1003,102 @@ class SolidityLSPTestSuite: # {{{ message = "Goto definition (" + description + ")" self.expect_equal(len(response['result']), 1, message) self.expect_location(response['result'][0], expected_uri, expected_lineNo, expected_startEndColumns) + + + def find_tag_with_range(self, test, target_range): + """ + Find and return the tag that represents the requested range otherwise + return None. + """ + markers = self.get_file_tags(test) + + for tag, tag_range in markers.items(): + if tag_range == target_range: + return str(tag) + + return None + + def replace_ranges_with_tags(self, content): + """ + Replace matching ranges with "@". + """ + + def recursive_iter(obj): + if isinstance(obj, dict): + yield obj + for item in obj.values(): + yield from recursive_iter(item) + elif any(isinstance(obj, t) for t in (list, tuple)): + for item in obj: + yield from recursive_iter(item) + + for item in recursive_iter(content): + if "uri" in item and "range" in item: + markers = self.get_file_tags(item["uri"][:-len(".sol")]) + for tag, tagRange in markers.items(): + if tagRange == item["range"]: + item["range"] = str(tag) + + # Convert JSON to string and split it at the quoted tags + split_by_tag = TEST_REGEXES.findQuotedTag.split(json.dumps(content, indent=4, sort_keys=True)) + + # remove the quotes and return result + return "".join(map(lambda p: p[1:-1] if p.startswith('"@') else p, split_by_tag)) + + def user_interaction_failed_diagnostics( + self, + solc: JsonRpcProcess, + test, + content, + current_diagnostics: TestParser.Diagnostics + ): + """ + Asks the user how to proceed after an error. + Returns True if the test/file should be ignored, otherwise False + """ + while True: + print("(u)pdate/(r)etry/(s)kip file?") + user_response = sys.stdin.read(1) + if user_response == "u": + while True: + try: + self.update_diagnostics_in_file(solc, test, content, current_diagnostics) + return False + # pragma pylint: disable=broad-except + except Exception as e: + print(e) + if ret := self.user_interaction_failed_autoupdate(test): + return ret + elif user_response == 's': + return True + elif user_response == 'r': + return False + + def user_interaction_failed_autoupdate(self, test): + print("(e)dit/(r)etry/(s)kip file?") + user_response = sys.stdin.read(1) + if user_response == "r": + print("retrying...") + # pragma pylint: disable=no-member + self.get_file_tags.cache_clear() + return False + if user_response == "e": + editor = os.environ.get('VISUAL', os.environ.get('EDITOR', 'vi')) + subprocess.run( + f'{editor} {self.get_test_file_path(test)}', + shell=True, + check=True + ) + # pragma pylint: disable=no-member + self.get_file_tags.cache_clear() + elif user_response == "s": + print("skipping...") + + return True + # }}} # {{{ actual tests - def test_publish_diagnostics_warnings(self, solc: JsonRpcProcess) -> None: - self.setup_lsp(solc) - TEST_NAME = 'publish_diagnostics_1' - published_diagnostics = self.open_file_and_wait_for_diagnostics(solc, TEST_NAME) - - self.expect_equal(len(published_diagnostics), 1, "One published_diagnostics message") - report = published_diagnostics[0] - - self.expect_equal(report['uri'], self.get_test_file_uri(TEST_NAME), "Correct file URI") - diagnostics = report['diagnostics'] - - markers = self.get_file_tags(TEST_NAME) - - self.expect_equal(len(diagnostics), 3, "3 diagnostic messages") - self.expect_diagnostic(diagnostics[0], code=6321, marker=markers["@unusedReturnVariable"]) - self.expect_diagnostic(diagnostics[1], code=2072, marker=markers["@unusedVariable"]) - self.expect_diagnostic(diagnostics[2], code=2072, marker=markers["@unusedContractVariable"]) - - def test_publish_diagnostics_errors(self, solc: JsonRpcProcess) -> None: - self.setup_lsp(solc) - TEST_NAME = 'publish_diagnostics_2' - published_diagnostics = self.open_file_and_wait_for_diagnostics(solc, TEST_NAME) - - self.expect_equal(len(published_diagnostics), 1, "One published_diagnostics message") - report = published_diagnostics[0] - - self.expect_equal(report['uri'], self.get_test_file_uri(TEST_NAME), "Correct file URI") - diagnostics = report['diagnostics'] - - markers = self.get_file_tags(TEST_NAME) - - self.expect_equal(len(diagnostics), 3, "3 diagnostic messages") - self.expect_diagnostic(diagnostics[0], code=9574, marker=markers["@conversionError"]) - self.expect_diagnostic(diagnostics[1], code=6777, marker=markers["@argumentsRequired"]) - self.expect_diagnostic(diagnostics[2], code=6160, marker=markers["@wrongArgumentsCount"]) def test_publish_diagnostics_errors_multiline(self, solc: JsonRpcProcess) -> None: self.setup_lsp(solc) @@ -510,7 +1125,7 @@ class SolidityLSPTestSuite: # {{{ def test_textDocument_didOpen_with_relative_import(self, solc: JsonRpcProcess) -> None: self.setup_lsp(solc) TEST_NAME = 'didOpen_with_import' - published_diagnostics = self.open_file_and_wait_for_diagnostics(solc, TEST_NAME, 2) + published_diagnostics = self.open_file_and_wait_for_diagnostics(solc, TEST_NAME) self.expect_equal(len(published_diagnostics), 2, "Diagnostic reports for 2 files") @@ -526,7 +1141,7 @@ class SolidityLSPTestSuite: # {{{ marker = self.get_file_tags("lib")["@diagnostics"] self.expect_diagnostic(report['diagnostics'][0], code=2072, marker=marker) - + @functools.lru_cache # pragma pylint: disable=lru-cache-decorating-method def get_file_tags(self, test_name: str, verbose=False): """ Finds all tags (e.g. @tagname) in the given test and returns them as a @@ -541,14 +1156,12 @@ class SolidityLSPTestSuite: # {{{ markers = {} - for lineNum, line in enumerate(content.splitlines(), start=-1): + for lineNum, line in tags_only(content.splitlines()): commentStart = line.find("//") - if commentStart == -1: - continue - for kind, regex in self.marker_regexes.items(): + for kind, regex in TAG_REGEXES._asdict().items(): for match in regex.finditer(line[commentStart:]): - if kind == Marker.SimpleRange: + if kind == "simpleRange": markers[match.group("tag")] = { "start": { "line": lineNum, @@ -558,7 +1171,7 @@ class SolidityLSPTestSuite: # {{{ "line": lineNum, "character": match.end("range") + commentStart }} - elif kind == Marker.MultilineRange: + elif kind == "multilineRange": if match.group("delimiter") == "(": markers[match.group("tag")] = \ { "start": { "line": lineNum, "character": 0 } } @@ -575,7 +1188,7 @@ class SolidityLSPTestSuite: # {{{ # Reusing another test but now change some file that generates an error in the other. self.test_textDocument_didOpen_with_relative_import(solc) marker = self.get_file_tags("lib")["@addFunction"] - self.open_file_and_wait_for_diagnostics(solc, 'lib', 2) + self.open_file_and_wait_for_diagnostics(solc, 'lib') solc.send_message( 'textDocument/didChange', { @@ -592,7 +1205,7 @@ class SolidityLSPTestSuite: # {{{ ] } ) - published_diagnostics = self.wait_for_diagnostics(solc, 2) + published_diagnostics = self.wait_for_diagnostics(solc) self.expect_equal(len(published_diagnostics), 2, "Diagnostic reports for 2 files") # Main file now contains a new diagnostic @@ -612,7 +1225,7 @@ class SolidityLSPTestSuite: # {{{ def test_textDocument_didOpen_with_relative_import_without_project_url(self, solc: JsonRpcProcess) -> None: self.setup_lsp(solc, expose_project_root=False) TEST_NAME = 'didOpen_with_import' - published_diagnostics = self.open_file_and_wait_for_diagnostics(solc, TEST_NAME, 2) + published_diagnostics = self.open_file_and_wait_for_diagnostics(solc, TEST_NAME) self.verify_didOpen_with_import_diagnostics(published_diagnostics) def verify_didOpen_with_import_diagnostics( @@ -632,9 +1245,42 @@ class SolidityLSPTestSuite: # {{{ self.expect_equal(report['uri'], self.get_test_file_uri('lib'), "Correct file URI") self.expect_equal(len(report['diagnostics']), 1, "one diagnostic") - marker = self.get_file_tags('lib')["@diagnostics"] + markers = self.get_file_tags('lib') + marker = markers["@diagnostics"] self.expect_diagnostic(report['diagnostics'][0], code=2072, marker=marker) + def test_generic(self, solc: JsonRpcProcess) -> None: + self.setup_lsp(solc) + + STATIC_TESTS = ['didChange_template', 'didOpen_with_import', 'publish_diagnostics_3'] + + tests = filter( + lambda x: x not in STATIC_TESTS, + map(lambda x: x[:-len(".sol")], os.listdir(self.project_root_dir)) + ) + + for test in tests: + try_again = True + print(f"Running test {test}") + + while try_again: + runner = FileTestRunner(test, solc, self) + + try: + runner.test_diagnostics() + try_again = not runner.test_methods() + except ExpectationFailed as e: + print(e) + + if e.part == e.Part.Diagnostics: + try_again = not self.user_interaction_failed_diagnostics( + solc, + test, + runner.content, + runner.expected_diagnostics + ) + else: + raise def test_textDocument_didChange_updates_diagnostics(self, solc: JsonRpcProcess) -> None: self.setup_lsp(solc) @@ -664,7 +1310,7 @@ class SolidityLSPTestSuite: # {{{ ] } ) - published_diagnostics = self.wait_for_diagnostics(solc, 1) + published_diagnostics = self.wait_for_diagnostics(solc) self.expect_equal(len(published_diagnostics), 1) report = published_diagnostics[0] self.expect_equal(report['uri'], self.get_test_file_uri(TEST_NAME), "Correct file URI") @@ -676,7 +1322,7 @@ class SolidityLSPTestSuite: # {{{ def test_textDocument_didChange_delete_line_and_close(self, solc: JsonRpcProcess) -> None: # Reuse this test to prepare and ensure it is as expected self.test_textDocument_didOpen_with_relative_import(solc) - self.open_file_and_wait_for_diagnostics(solc, 'lib', 2) + self.open_file_and_wait_for_diagnostics(solc, 'lib') marker = self.get_file_tags('lib')["@diagnostics"] @@ -697,7 +1343,7 @@ class SolidityLSPTestSuite: # {{{ ] } ) - published_diagnostics = self.wait_for_diagnostics(solc, 2) + published_diagnostics = self.wait_for_diagnostics(solc) self.expect_equal(len(published_diagnostics), 2, "published diagnostics count") report1 = published_diagnostics[0] self.expect_equal(report1['uri'], self.get_test_file_uri('didOpen_with_import'), "Correct file URI") @@ -712,7 +1358,7 @@ class SolidityLSPTestSuite: # {{{ { 'textDocument': { 'uri': self.get_test_file_uri('lib') }} ) - published_diagnostics = self.wait_for_diagnostics(solc, 2) + published_diagnostics = self.wait_for_diagnostics(solc) self.verify_didOpen_with_import_diagnostics(published_diagnostics) def test_textDocument_opening_two_new_files_edit_and_close(self, solc: JsonRpcProcess) -> None: @@ -734,7 +1380,7 @@ class SolidityLSPTestSuite: # {{{ ]) } }) - reports = self.wait_for_diagnostics(solc, 1) + reports = self.wait_for_diagnostics(solc) self.expect_equal(len(reports), 1, "one publish diagnostics notification") self.expect_equal(len(reports[0]['diagnostics']), 0, "should not contain diagnostics") @@ -750,7 +1396,7 @@ class SolidityLSPTestSuite: # {{{ ]) } }) - reports = self.wait_for_diagnostics(solc, 2) + reports = self.wait_for_diagnostics(solc) self.expect_equal(len(reports), 2, "one publish diagnostics notification") self.expect_equal(len(reports[0]['diagnostics']), 0, "should not contain diagnostics") self.expect_equal(len(reports[1]['diagnostics']), 0, "should not contain diagnostics") @@ -769,7 +1415,7 @@ class SolidityLSPTestSuite: # {{{ } ] }) - reports = self.wait_for_diagnostics(solc, 2) + reports = self.wait_for_diagnostics(solc) self.expect_equal(len(reports), 2, "one publish diagnostics notification") self.expect_equal(len(reports[0]['diagnostics']), 0, "should not contain diagnostics") self.expect_equal(len(reports[1]['diagnostics']), 0, "should not contain diagnostics") @@ -779,7 +1425,7 @@ class SolidityLSPTestSuite: # {{{ { 'textDocument': { 'uri': FILE_B_URI }} ) # We only get one diagnostics message since the diagnostics for b.sol was empty. - reports = self.wait_for_diagnostics(solc, 1) + reports = self.wait_for_diagnostics(solc) self.expect_equal(len(reports), 1, "one publish diagnostics notification") self.expect_diagnostic(reports[0]['diagnostics'][0], 6275, 2, (0, 17)) # a.sol: File B not found self.expect_equal(reports[0]['uri'], FILE_A_URI, "Correct uri") @@ -804,7 +1450,7 @@ class SolidityLSPTestSuite: # {{{ 'import "./lib.sol";\n' } }) - reports = self.wait_for_diagnostics(solc, 2) + reports = self.wait_for_diagnostics(solc) self.expect_equal(len(reports), 2, '') self.expect_equal(len(reports[0]['diagnostics']), 0, "should not contain diagnostics") @@ -818,12 +1464,11 @@ class SolidityLSPTestSuite: # {{{ 'textDocument/didClose', { 'textDocument': { 'uri': FILE_A_URI }} ) - reports = self.wait_for_diagnostics(solc, 1) + reports = self.wait_for_diagnostics(solc) self.expect_equal(len(reports), 1, '') self.expect_equal(reports[0]['uri'], f'file://{self.project_root_dir}/lib.sol', "") self.expect_equal(len(reports[0]['diagnostics']), 0, "should not contain diagnostics") - def test_textDocument_didChange_at_eol(self, solc: JsonRpcProcess) -> None: """ Append at one line and insert a new one below. @@ -839,7 +1484,7 @@ class SolidityLSPTestSuite: # {{{ 'text': self.get_test_file_contents(FILE_NAME) } }) - published_diagnostics = self.wait_for_diagnostics(solc, 1) + published_diagnostics = self.wait_for_diagnostics(solc) self.expect_equal(len(published_diagnostics), 1, "one publish diagnostics notification") self.expect_equal(len(published_diagnostics[0]['diagnostics']), 0, "no diagnostics") solc.send_message('textDocument/didChange', { @@ -856,7 +1501,7 @@ class SolidityLSPTestSuite: # {{{ } ] }) - published_diagnostics = self.wait_for_diagnostics(solc, 1) + published_diagnostics = self.wait_for_diagnostics(solc) self.expect_equal(len(published_diagnostics), 1, "one publish diagnostics notification") report2 = published_diagnostics[0] self.expect_equal(report2['uri'], FILE_URI, "Correct file URI") @@ -875,238 +1520,13 @@ class SolidityLSPTestSuite: # {{{ } ] }) - published_diagnostics = self.wait_for_diagnostics(solc, 1) + published_diagnostics = self.wait_for_diagnostics(solc) self.expect_equal(len(published_diagnostics), 1, "one publish diagnostics notification") report3 = published_diagnostics[0] self.expect_equal(report3['uri'], FILE_URI, "Correct file URI") self.expect_equal(len(report3['diagnostics']), 1, "one diagnostic") self.expect_diagnostic(report3['diagnostics'][0], 4126, 6, (1, 23)) - def test_textDocument_definition(self, solc: JsonRpcProcess) -> None: - self.setup_lsp(solc) - FILE_NAME = 'goto_definition' - FILE_URI = self.get_test_file_uri(FILE_NAME) - LIB_URI = self.get_test_file_uri('lib') - solc.send_message('textDocument/didOpen', { - 'textDocument': { - 'uri': FILE_URI, - 'languageId': 'Solidity', - 'version': 1, - 'text': self.get_test_file_contents(FILE_NAME) - } - }) - published_diagnostics = self.wait_for_diagnostics(solc, 2) - self.expect_equal(len(published_diagnostics), 2, "publish diagnostics for 2 files") - self.expect_equal(len(published_diagnostics[0]['diagnostics']), 0) - self.expect_equal(len(published_diagnostics[1]['diagnostics']), 1) - self.expect_diagnostic(published_diagnostics[1]['diagnostics'][0], 2072, 33, (8, 19)) # unused variable in lib.sol - - # import directive - self.expect_goto_definition_location( - solc=solc, - document_uri=FILE_URI, - document_position=(3, 9), # symbol `"./lib.sol"` in `import "./lib.sol"` - expected_uri=LIB_URI, - expected_lineNo=0, - expected_startEndColumns=(0, 0), - description="import directive" - ) - - # type symbol to jump to type defs (error, contract, enum, ...) - self.expect_goto_definition_location( - solc=solc, - document_uri=FILE_URI, - document_position=(30, 19), # symbol `IA` in `new IA()` - expected_uri=FILE_URI, - expected_lineNo=10, - expected_startEndColumns=(9, 11), - description="type symbol to jump to definition" - ) - - # virtual function lookup? - self.expect_goto_definition_location( - solc=solc, - document_uri=FILE_URI, - document_position=(31, 12), # symbol `f`, jumps to interface definition - expected_uri=FILE_URI, - expected_lineNo=7, - expected_startEndColumns=(13, 14), - description="virtual function lookup" - ) - - # using for - self.expect_goto_definition_location( - solc=solc, - document_uri=FILE_URI, - document_position=(37, 10), # symbol `add` in `i.add(5)` - expected_uri=FILE_URI, - expected_lineNo=22, - expected_startEndColumns=(13, 16), - description="using for" - ) - - # library - self.expect_goto_definition_location( - solc=solc, - document_uri=FILE_URI, - document_position=(43, 15), # symbol `Lib` in `Lib.add(n, 1)` - expected_uri=LIB_URI, - expected_lineNo=22, - expected_startEndColumns=(8, 11), - description="Library symbol from different file" - ) - self.expect_goto_definition_location( - solc=solc, - document_uri=FILE_URI, - document_position=(43, 19), # symbol `add` in `Lib.add(n, 1)` - expected_uri=LIB_URI, - expected_lineNo=24, - expected_startEndColumns=(13, 16), - description="Library member symbol from different file" - ) - - # enum type symbol and enum values - self.expect_goto_definition_location( - solc=solc, - document_uri=FILE_URI, - document_position=(46, 19), # symbol `Color` in function signature's parameter - expected_uri=LIB_URI, - expected_lineNo=13, - expected_startEndColumns=(5, 10), - description="Enum type" - ) - self.expect_goto_definition_location( - solc=solc, - document_uri=FILE_URI, - document_position=(48, 24), # symbol `Red` in `Color.Red` - expected_uri=LIB_URI, - expected_lineNo=15, - expected_startEndColumns=(4, 7), - description="Enum value" - ) - self.expect_goto_definition_location( - solc=solc, - document_uri=FILE_URI, - document_position=(48, 24), # symbol `Red` in `Color.Red` - expected_uri=LIB_URI, - expected_lineNo=15, - expected_startEndColumns=(4, 7), - description="Enum value" - ) - - # local variable declarations - self.expect_goto_definition_location( - solc=solc, - document_uri=FILE_URI, - document_position=(49, 17), # symbol `e` in `(c == e)` - expected_uri=FILE_URI, - expected_lineNo=48, - expected_startEndColumns=(14, 15), - description="local variable declaration" - ) - - # User defined type - self.expect_goto_definition_location( - solc=solc, - document_uri=FILE_URI, - document_position=(58, 8), # symbol `Price` in `Price p ...` - expected_uri=FILE_URI, - expected_lineNo=55, - expected_startEndColumns=(9, 14), - description="User defined type on left hand side" - ) - - self.expect_goto_definition_location( - solc=solc, - document_uri=FILE_URI, - document_position=(58, 18), # symbol `Price` in `Price.wrap()` expected_uri=FILE_URI, - expected_uri=FILE_URI, - expected_lineNo=55, - expected_startEndColumns=(9, 14), - description="User defined type on right hand side." - ) - - # struct constructor also properly jumps to the struct's declaration. - self.expect_goto_definition_location( - solc=solc, - document_uri=FILE_URI, - document_position=(64, 33), # symbol `RGBColor` right hand side expression. - expected_uri=LIB_URI, - expected_lineNo=38, - expected_startEndColumns=(7, 15), - description="Struct constructor." - ) - - def test_textDocument_definition_imports(self, solc: JsonRpcProcess) -> None: - self.setup_lsp(solc) - FILE_NAME = 'goto_definition_imports' - FILE_URI = self.get_test_file_uri(FILE_NAME) - LIB_URI = self.get_test_file_uri('lib') - solc.send_message('textDocument/didOpen', { - 'textDocument': { - 'uri': FILE_URI, - 'languageId': 'Solidity', - 'version': 1, - 'text': self.get_test_file_contents(FILE_NAME) - } - }) - published_diagnostics = self.wait_for_diagnostics(solc, 2) - self.expect_equal(len(published_diagnostics), 2, "publish diagnostics for 2 files") - self.expect_equal(len(published_diagnostics[0]['diagnostics']), 0) - self.expect_equal(len(published_diagnostics[1]['diagnostics']), 1) - self.expect_diagnostic(published_diagnostics[1]['diagnostics'][0], 2072, 33, (8, 19)) # unused variable in lib.sol - - # import directive: test symbol alias - self.expect_goto_definition_location( - solc=solc, - document_uri=FILE_URI, - document_position=(3, 9), # in `Weather` of `import {Weather as Wetter} from "./lib.sol"` - expected_uri=LIB_URI, - expected_lineNo=6, - expected_startEndColumns=(5, 12), - description="goto definition of symbol in symbol alias import directive" - ) - - # import directive: test symbol alias - self.expect_goto_definition_location( - solc=solc, - document_uri=FILE_URI, - document_position=(8, 55), # `Wetter` in return type declaration - expected_uri=LIB_URI, - expected_lineNo=6, - expected_startEndColumns=(5, 12), - description="goto definition of symbol in symbol alias import directive" - ) - - # That.Color tests with `That` being the aliased library to be imported. - self.expect_goto_definition_location( - solc=solc, - document_uri=FILE_URI, - document_position=(13, 55), # `That` in return type declaration - expected_uri=LIB_URI, - expected_lineNo=13, - expected_startEndColumns=(5, 10), - description="goto definition of symbol in symbol alias import directive" - ) - self.expect_goto_definition_location( - solc=solc, - document_uri=FILE_URI, - document_position=(15, 8), - expected_uri=LIB_URI, - expected_lineNo=13, - expected_startEndColumns=(5, 10), - description="`That` in LHS variable assignment" - ) - self.expect_goto_definition_location( - solc=solc, - document_uri=FILE_URI, - document_position=(15, 27), - expected_uri=FILE_URI, - expected_lineNo=4, - expected_startEndColumns=(22, 26), - description="`That` in expression" - ) - def test_textDocument_didChange_empty_file(self, solc: JsonRpcProcess) -> None: """ Starts with an empty file and changes it to look like @@ -1126,7 +1546,7 @@ class SolidityLSPTestSuite: # {{{ 'text': '' } }) - reports = self.wait_for_diagnostics(solc, 1) + reports = self.wait_for_diagnostics(solc) self.expect_equal(len(reports), 1) report = reports[0] published_diagnostics = report['diagnostics'] @@ -1147,7 +1567,7 @@ class SolidityLSPTestSuite: # {{{ } ] }) - published_diagnostics = self.wait_for_diagnostics(solc, 2) + published_diagnostics = self.wait_for_diagnostics(solc) self.verify_didOpen_with_import_diagnostics(published_diagnostics, 'a_new_file') def test_textDocument_didChange_multi_line(self, solc: JsonRpcProcess) -> None: @@ -1166,7 +1586,7 @@ class SolidityLSPTestSuite: # {{{ 'text': self.get_test_file_contents(FILE_NAME) } }) - published_diagnostics = self.wait_for_diagnostics(solc, 1) + published_diagnostics = self.wait_for_diagnostics(solc) self.expect_equal(len(published_diagnostics), 1, "one publish diagnostics notification") self.expect_equal(len(published_diagnostics[0]['diagnostics']), 0, "no diagnostics") solc.send_message('textDocument/didChange', { @@ -1181,7 +1601,7 @@ class SolidityLSPTestSuite: # {{{ } ] }) - published_diagnostics = self.wait_for_diagnostics(solc, 1) + published_diagnostics = self.wait_for_diagnostics(solc) self.expect_equal(len(published_diagnostics), 1, "one publish diagnostics notification") report2 = published_diagnostics[0] self.expect_equal(report2['uri'], FILE_URI, "Correct file URI") @@ -1201,7 +1621,7 @@ class SolidityLSPTestSuite: # {{{ } ] }) - published_diagnostics = self.wait_for_diagnostics(solc, 1) + published_diagnostics = self.wait_for_diagnostics(solc) self.expect_equal(len(published_diagnostics), 1, "one publish diagnostics notification") report3 = published_diagnostics[0] self.expect_equal(report3['uri'], FILE_URI, "Correct file URI") @@ -1233,6 +1653,9 @@ class SolidityLSPTestSuite: # {{{ # }}} if __name__ == "__main__": + # Turn off user input buffering so we get the input immediately, + # not only after a line break + tty.setcbreak(sys.stdin.fileno()) suite = SolidityLSPTestSuite() exit_code = suite.main() sys.exit(exit_code)