diff --git a/third-party/chpl-venv/Makefile b/third-party/chpl-venv/Makefile index 955139b68b2d..1f6bc78b8aea 100644 --- a/third-party/chpl-venv/Makefile +++ b/third-party/chpl-venv/Makefile @@ -99,7 +99,8 @@ chapel-py-venv: install-chpldeps export VIRTUAL_ENV=$(CHPL_VENV_VIRTUALENV_DIR) && \ $(PIP) install --upgrade $(CHPL_PIP_INSTALL_PARAMS) $(LOCAL_PIP_FLAGS) \ --target $(CHPL_VENV_CHPLDEPS) \ - -e $(CHPL_MAKE_HOME)/tools/chapel-py + -e $(CHPL_MAKE_HOME)/tools/chapel-py \ + -r $(CHPL_VENV_CHPLCHECK_REQUIREMENTS_FILE) install-requirements: install-chpldeps diff --git a/third-party/chpl-venv/Makefile.include b/third-party/chpl-venv/Makefile.include index aa1121631b60..0a45307a71b8 100644 --- a/third-party/chpl-venv/Makefile.include +++ b/third-party/chpl-venv/Makefile.include @@ -10,6 +10,7 @@ CHPL_VENV_CHPLDOC_REQUIREMENTS_FILE1=$(CHPL_VENV_DIR)/chpldoc-requirements1.txt CHPL_VENV_CHPLDOC_REQUIREMENTS_FILE2=$(CHPL_VENV_DIR)/chpldoc-requirements2.txt CHPL_VENV_CHPLDOC_REQUIREMENTS_FILE3=$(CHPL_VENV_DIR)/chpldoc-requirements3.txt CHPL_VENV_C2CHAPEL_REQUIREMENTS_FILE=$(CHPL_VENV_DIR)/c2chapel-requirements.txt +CHPL_VENV_CHPLCHECK_REQUIREMENTS_FILE=$(CHPL_VENV_DIR)/chplcheck-requirements.txt CHPL_VENV_CHPLSPELL_REQUIREMENTS_FILE=$(CHPL_VENV_DIR)/chplspell-requirements.txt CHPL_VENV_CHPLSPELL_REQS=$(CHPL_VENV_VIRTUALENV_DIR)/chpl-chplspell-reqs diff --git a/third-party/chpl-venv/chplcheck-requirements.txt b/third-party/chpl-venv/chplcheck-requirements.txt new file mode 100644 index 000000000000..f13f3acf1e01 --- /dev/null +++ b/third-party/chpl-venv/chplcheck-requirements.txt @@ -0,0 +1,5 @@ +attrs==23.1.0 +cattrs==23.1.2 +lsprotocol==2023.0.0b1 +pygls==1.1.1 +typeguard==3.0.2 diff --git a/tools/chapel-py/chapel.cpp b/tools/chapel-py/chapel.cpp index 3ab934d9f679..4ab66d5699c6 100644 --- a/tools/chapel-py/chapel.cpp +++ b/tools/chapel-py/chapel.cpp @@ -369,9 +369,22 @@ static PyObject* ContextObject_is_bundled_path(ContextObject *self, PyObject* ar return PyBool_FromLong(isInternalPath); } +static PyObject* ContextObject_advance_to_next_revision(ContextObject *self, PyObject* args) { + auto context = &self->context; + bool prepareToGc; + if (!PyArg_ParseTuple(args, "b", &prepareToGc)) { + PyErr_BadArgument(); + return nullptr; + } + + context->advanceToNextRevision(prepareToGc); + Py_RETURN_NONE; +} + static PyMethodDef ContextObject_methods[] = { { "parse", (PyCFunction) ContextObject_parse, METH_VARARGS, "Parse a top-level AST node from the given file" }, { "is_bundled_path", (PyCFunction) ContextObject_is_bundled_path, METH_VARARGS, "Check if the given file path is within the bundled (built-in) Chapel files" }, + { "advance_to_next_revision", (PyCFunction) ContextObject_advance_to_next_revision, METH_VARARGS, "Advance the context to the next revision" }, {NULL, NULL, 0, NULL} /* Sentinel */ }; diff --git a/tools/chapel-py/chapel/__init__.py b/tools/chapel-py/chapel/__init__.py index 942f4458d965..11f02cf0c586 100644 --- a/tools/chapel-py/chapel/__init__.py +++ b/tools/chapel-py/chapel/__init__.py @@ -19,6 +19,8 @@ # from . import core +from collections import defaultdict +import os def preorder(node): """ @@ -219,3 +221,30 @@ def each_matching(node, pattern): variables = match_pattern(child, pattern) if variables is not None: yield (child, variables) + +def files_with_contexts(files): + """ + Some files might have the same name, which Dyno really doesn't like. + Stratify files into "buckets"; within each bucket, all filenames are + unique. Between each bucket, re-create the Dyno context to avoid giving + it complicting files. + + Yields files from the argument, as well as the context created for them. + """ + + basenames = defaultdict(lambda: 0) + buckets = defaultdict(lambda: []) + for filename in files: + filename = os.path.realpath(os.path.expandvars(filename)) + + basename = os.path.basename(filename) + bucket = basenames[basename] + basenames[basename] += 1 + buckets[bucket].append(filename) + + for bucket in buckets: + ctx = core.Context() + to_yield = buckets[bucket] + + for filename in to_yield: + yield (filename, ctx) diff --git a/tools/chapel-py/chapel/replace.py b/tools/chapel-py/chapel/replace.py index f4a90929a7aa..2777f968a265 100644 --- a/tools/chapel-py/chapel/replace.py +++ b/tools/chapel-py/chapel/replace.py @@ -21,7 +21,6 @@ import argparse import chapel import chapel.core -from collections import defaultdict import os import sys @@ -96,11 +95,14 @@ def rename_formals(rc, fn, renames): updates that perform the formal renaming. """ + def name_replacer(name): + return lambda child_text: child_text.replace(name, renames[name]) + for child in fn.formals(): name = child.name() if name not in renames: continue - yield (child, lambda child_text: child_text.replace(name, renames[name])) + yield (child, name_replacer(name)) def rename_named_actuals(rc, call, renames): """ @@ -129,6 +131,10 @@ def _do_replace(finder, ctx, filename, suffix, inplace): # and apply the transformations. nodes_to_replace = {} + + def compose(outer, inner): + return lambda text: outer(inner(text)) + for ast in asts: for (node, replace_with) in finder(rc, ast): uid = node.unique_id() @@ -141,7 +147,7 @@ def _do_replace(finder, ctx, filename, suffix, inplace): elif uid in nodes_to_replace: # Old substitution is also a callable; need to create composition. if callable(nodes_to_replace[uid]): - nodes_to_replace[uid] = lambda text: replace_with(nodes_to_replace[uid](text)) + nodes_to_replace[uid] = compose(replace_with, nodes_to_replace[uid]) # Old substitution is a string; we can apply the callable to get # another string. else: @@ -213,27 +219,8 @@ def run(finder, name='replace', description='A tool to search-and-replace Chapel parser.add_argument('--in-place', dest='inplace', action='store_true', default=False) args = parser.parse_args() - # Some files might have the same name, which Dyno really doesn't like. - # Strateify files into "buckets"; within each bucket, all filenames are - # unique. Between each bucket, re-create the Dyno context to avoid giving - # it complicting files. - - basenames = defaultdict(lambda: 0) - buckets = defaultdict(lambda: []) - for filename in args.filenames: - filename = os.path.realpath(os.path.expandvars(filename)) - - basename = os.path.basename(filename) - bucket = basenames[basename] - basenames[basename] += 1 - buckets[bucket].append(filename) - - for bucket in buckets: - ctx = chapel.core.Context() - to_replace = buckets[bucket] - - for filename in to_replace: - _do_replace(finder, ctx, filename, args.suffix, args.inplace) + for (filename, ctx) in chapel.files_with_contexts(args.filenames): + _do_replace(finder, ctx, filename, args.suffix, args.inplace) def fuse(*args): """ diff --git a/tools/chapel-py/chplcheck.py b/tools/chapel-py/chplcheck.py deleted file mode 100755 index 5133e45c01bb..000000000000 --- a/tools/chapel-py/chplcheck.py +++ /dev/null @@ -1,186 +0,0 @@ -#!/usr/bin/env python3 - -# -# Copyright 2020-2023 Hewlett Packard Enterprise Development LP -# Copyright 2004-2019 Cray Inc. -# Other additional copyright holders may be indicated within. -# -# The entirety of this work is licensed under the Apache License, -# Version 2.0 (the "License"); you may not use this file except -# in compliance with the License. -# -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -import chapel -import chapel.core -import chapel.replace -import re -import sys -import argparse - -# === Driver Functions: the code of the linter === - -IgnoreAttr = ("chplcheck.ignore", ["rule", "comment"]) -def ignores_rule(node, rulename): - ag = node.attribute_group() - - if ag is None: return False - for attr in ag: - attr_call = chapel.parse_attribute(attr, IgnoreAttr) - if attr_call is None: continue - - ignored_rule = attr_call["rule"] - if ignored_rule is not None and ignored_rule.value() == rulename: - return True - - return False - -def report_violation(node, name): - if name in args.ignored_rules: - return - - location = node.location() - first_line, _ = location.start() - print("{}:{}: node violates rule {}".format(location.path(), first_line, name)) - -def check_basic_rule(root, rule): - (name, nodetype, func) = rule - for (node, _) in chapel.each_matching(root, nodetype): - if ignores_rule(node, name): continue - - if not func(node): report_violation(node, name) - - -# === "user-defined" linter rule functions, used to implement warnings. === - -def consecutive_decls(node): - def is_relevant_decl(node): - if isinstance(node, chapel.core.MultiDecl): - for child in node: - if isinstance(child, chapel.core.Variable): return child.kind() - elif isinstance(node, chapel.core.Variable): - return node.kind() - - return None - - def recurse(node, skip_direct = False): - consecutive = [] - last_kind = None - last_has_attribute = False - - for child in node: - yield from recurse(child, skip_direct = isinstance(child, chapel.core.MultiDecl)) - - if skip_direct: continue - - new_kind = is_relevant_decl(child) - has_attribute = child.attribute_group() is not None - any_has_attribute = last_has_attribute or has_attribute - compatible_kinds = not any_has_attribute and (last_kind is None or last_kind == new_kind) - last_kind = new_kind - last_has_attribute = has_attribute - - # If we ran out of compatible decls, see if we can return them. - if not compatible_kinds: - if len(consecutive) > 1: - yield consecutive - consecutive = [] - - # If this could be a compatible decl, start a new list. - if new_kind is not None: - consecutive.append(child) - - if len(consecutive) > 1: - yield consecutive - - yield from recurse(node) - -def check_nested_coforall(node): - parent = node.parent() - while parent is not None: - if isinstance(parent, chapel.core.Coforall): - return False - parent = parent.parent() - return True - -def name_for_linting(node): - name = node.name() - if name.startswith("chpl_"): - name = name.removeprefix("chpl_") - return name - -def check_camel_case(node): - return re.fullmatch(r'_?([a-z]+([A-Z][a-z]+|\d+)*|[A-Z]+)\$?', name_for_linting(node)) - -def check_camel_case_var(node): - if node.name() == "_": return True - return check_camel_case(node) - -def check_pascal_case(node): - return re.fullmatch(r'_?(([A-Z][a-z]+|\d+)+|[A-Z]+)\$?', name_for_linting(node)) - -def check_reserved_prefix(node): - if node.name().startswith("chpl_"): - path = node.location().path() - return ctx.is_bundled_path(path) - return True - -def check_redundant_block(node): - return node.block_style() != "unnecessary" - -def check_misleading_indentation(node): - prev = None - for child in node: - yield from check_misleading_indentation(child) - - if prev is not None: - if child.location().start()[1] == prev.location().start()[1]: - yield child - - if isinstance(child, chapel.core.Loop) and child.block_style() == "implicit": - grandchildren = list(child) - if len(grandchildren) > 0: - prev = list(grandchildren[-1])[0] - -Rules = [ - ("CamelCaseVariables", chapel.core.VarLikeDecl, check_camel_case_var), - ("CamelCaseRecords", chapel.core.Record, check_camel_case), - ("PascalCaseClasses", chapel.core.Class, check_pascal_case), - ("PascalCaseModules", chapel.core.Module, check_pascal_case), - ("DoKeywordAndBlock", chapel.core.Loop, check_redundant_block), - ("NestedCoforalls", chapel.core.Coforall, check_nested_coforall), - ("BoolLitInCondStmt", [chapel.core.Conditional, chapel.core.BoolLiteral, chapel.rest], lambda node: False), - ("ChplPrefixReserved", chapel.core.NamedDecl, check_reserved_prefix), -] - -def main(): - global args - - parser = argparse.ArgumentParser( prog='chplcheck', description='A linter for the Chapel language') - parser.add_argument('filename') - parser.add_argument('--ignore-rule', action='append', dest='ignored_rules', default=[]) - args = parser.parse_args() - - ctx = chapel.core.Context() - ast = ctx.parse(args.filename) - - for rule in Rules: - check_basic_rule(ast, rule) - - for group in consecutive_decls(ast): - report_violation(group[1], "ConsecutiveDecls") - - for node in check_misleading_indentation(ast): - report_violation(node, "MisleadingIndentation") - -if __name__ == "__main__": - main() diff --git a/tools/chapel-py/chplcheck/__init__.py b/tools/chapel-py/chplcheck/__init__.py new file mode 100755 index 000000000000..aaa4f7d15fd1 --- /dev/null +++ b/tools/chapel-py/chplcheck/__init__.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 + +# +# Copyright 2020-2023 Hewlett Packard Enterprise Development LP +# Copyright 2004-2019 Cray Inc. +# Other additional copyright holders may be indicated within. +# +# The entirety of this work is licensed under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import chapel +import chapel.core +import chapel.replace +import sys +import argparse +from driver import LintDriver +from rules import register_rules +from lsp import run_lsp + +def print_violation(node, name): + location = node.location() + first_line, _ = location.start() + print("{}:{}: node violates rule {}".format(location.path(), first_line, name)) + +def main(): + parser = argparse.ArgumentParser( prog='chplcheck', description='A linter for the Chapel language') + parser.add_argument('filenames', nargs='*') + parser.add_argument('--disable-rule', action='append', dest='disabled_rules', default=[]) + parser.add_argument('--enable-rule', action='append', dest='enabled_rules', default=[]) + parser.add_argument('--lsp', action='store_true', default=False) + args = parser.parse_args() + + driver = LintDriver() + driver.disable_rules("CamelCaseVariables", "ConsecutiveDecls") + driver.disable_rules(*args.disabled_rules) + driver.enable_rules(*args.enabled_rules) + register_rules(driver) + + if args.lsp: + run_lsp(driver) + return + + for (filename, context) in chapel.files_with_contexts(args.filenames): + asts = context.parse(filename) + for (node, rule) in driver.run_checks(context, asts): + print_violation(node, rule) + +if __name__ == "__main__": + main() diff --git a/tools/chapel-py/chplcheck/driver.py b/tools/chapel-py/chplcheck/driver.py new file mode 100644 index 000000000000..4d114d78625a --- /dev/null +++ b/tools/chapel-py/chplcheck/driver.py @@ -0,0 +1,153 @@ +# +# Copyright 2020-2023 Hewlett Packard Enterprise Development LP +# Copyright 2004-2019 Cray Inc. +# Other additional copyright holders may be indicated within. +# +# The entirety of this work is licensed under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import chapel +import chapel.core + +IgnoreAttr = ("chplcheck.ignore", ["rule", "comment"]) +def ignores_rule(node, rulename): + """ + Given an AST node, check if it has an attribute telling it to silence + warnings for a given rule. + """ + + ag = node.attribute_group() + + if ag is None: return False + for attr in ag: + attr_call = chapel.parse_attribute(attr, IgnoreAttr) + if attr_call is None: continue + + ignored_rule = attr_call["rule"] + if ignored_rule is not None and ignored_rule.value() == rulename: + return True + + return False + +class LintDriver: + """ + Driver class containing the state and methods for linting. Among other + things, contains the rules for emitting warnings, as well as the + list of rules that should be silenced. + + Provides the @driver.basic_rule and @driver.advanced_rule decorators + for registering new rules. + """ + + def __init__(self): + self.SilencedRules = [] + self.BasicRules = [] + self.AdvancedRules = [] + + def disable_rules(self, *rules): + """ + Tell the driver to silence / skip warning for the given rules. + """ + + self.SilencedRules.extend(rules) + + def enable_rules(self, *rules): + """ + Tell the driver to warn for the given rules even if they were + previously disabled. + """ + + self.SilencedRules = list(set(self.SilencedRules) - set(rules)) + + def _should_check_rule(self,rulename, node = None): + if rulename in self.SilencedRules: + return False + + if node is not None and ignores_rule(node, rulename): + return False + + return True + + def _check_basic_rule(self, context, root, rule): + (name, nodetype, func) = rule + + # If we should ignore the rule no matter the node, no reason to run + # a traversal and match the pattern. + if not self._should_check_rule(name): + return + + for (node, _) in chapel.each_matching(root, nodetype): + if not self._should_check_rule(name, node): + continue + + if not func(context, node): + yield (node, name) + + def _check_advanced_rule(self, context, root, rule): + (name, func) = rule + + # If we should ignore the rule no matter the node, no reason to run + # a traversal and match the pattern. + if not self._should_check_rule(name): + return + + for node in func(context, root): + # It's not clear how, if it all, advanced rules should be silenced + # by attributes (i.e., where do you put the @chplcheck.ignore + # attribute?). For now, do not silence them on a per-node basis. + + yield (node, name) + + def basic_rule(self, pat): + """ + This method is a decorator factory for adding 'basic' rules to the + driver. A basic rule is a function returning a boolean that gets called + on any node that matches a pattern. If the function returns 'True', the + node is good, and no warning is emitted. However, if the function returns + 'False', the node violates the rule. + + The name of the decorated function is used as the name of the rule. + """ + + def wrapper(func): + self.BasicRules.append((func.__name__, pat, func)) + return func + return wrapper + + def advanced_rule(self, func): + """ + This method is a decorator for adding 'advanced' rules to the driver. + An advanced rule is a function that gets called on a root AST node, + and is expected to traverse that AST to find places where warnings + need to be emitted. + + The name of the decorated function is used as the name of the rule. + """ + + self.AdvancedRules.append((func.__name__, func)) + return func + + def run_checks(self, context, asts): + """ + Runs all the rules registered with this node, yielding warnings for + all non-silenced rules that are violated in the given ASTs. + """ + + for ast in asts: + for rule in self.BasicRules: + yield from self._check_basic_rule(context, ast, rule) + + for rule in self.AdvancedRules: + yield from self._check_advanced_rule(context, ast, rule) diff --git a/tools/chapel-py/chplcheck/lsp.py b/tools/chapel-py/chplcheck/lsp.py new file mode 100644 index 000000000000..c20f9cafa11f --- /dev/null +++ b/tools/chapel-py/chplcheck/lsp.py @@ -0,0 +1,111 @@ +# +# Copyright 2020-2023 Hewlett Packard Enterprise Development LP +# Copyright 2004-2019 Cray Inc. +# Other additional copyright holders may be indicated within. +# +# The entirety of this work is licensed under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import chapel.core +from pygls.server import LanguageServer +from lsprotocol.types import TEXT_DOCUMENT_DID_OPEN, DidOpenTextDocumentParams +from lsprotocol.types import TEXT_DOCUMENT_DID_SAVE, DidSaveTextDocumentParams +from lsprotocol.types import Diagnostic, Range, Position, DiagnosticSeverity + +def location_to_range(location): + """ + Convert a Chapel location into a lsprotocol.types Range, which is + used for e.g. reporting diagnostics. + """ + + start = location.start() + end = location.end() + return Range( + start=Position(start[0]-1, start[1]-1), + end=Position(end[0]-1, end[1]-1) + ) + +def run_lsp(driver): + """ + Start a language server on the standard input/output, and use it to + report linter warnings as LSP diagnostics. + """ + + server = LanguageServer('chplcheck', 'v0.1') + + contexts = {} + def get_updated_context(uri): + """ + The LSP driver maintains one Chapel context per-file. This is to avoid + having to reset all files' text etc. when a single file is updated. + There may be a more principled approach we can take in the future. + + This function returns an _update_ context, which is effectively a context + in which we can save / make use of updated file text. If there wasn't + a context for a URI, a brand new context will do. For existing contexts, + an older version of the file's text is probably stored, so advance + the context to next revision, invalidating that cache. + + Thus, this method is effectively allocate-or-advance-context. + """ + + if uri in contexts: + context = contexts[uri] + context.advance_to_next_revision(False) + else: + context = chapel.core.Context() + contexts[uri] = context + return context + + def parse_file(context, uri): + """ + Given a file URI, return the ASTs making up that file. Advances + the context if one already exists to make sure an updated result + is returned. + """ + + return context.parse(uri[len("file://"):]) + + def build_diagnostics(uri): + """ + Parse a file at a particular URI, run the linter rules on the resulting + ASTs, and return them as LSP diagnostics. + """ + + context = get_updated_context(uri) + asts = parse_file(context, uri) + diagnostics = [] + for (node, rule) in driver.run_checks(context, asts): + diagnostic = Diagnostic( + range= location_to_range(node.location()), + message="Lint: rule [{}] violated".format(rule), + severity=DiagnosticSeverity.Warning + ) + diagnostics.append(diagnostic) + return diagnostics + + # The following functions are handlers for LSP events received by the server. + + @server.feature(TEXT_DOCUMENT_DID_OPEN) + async def did_open(ls, params: DidOpenTextDocumentParams): + text_doc = ls.workspace.get_text_document(params.text_document.uri) + ls.publish_diagnostics(text_doc.uri, build_diagnostics(text_doc.uri)) + + @server.feature(TEXT_DOCUMENT_DID_SAVE) + async def did_save(ls, params: DidSaveTextDocumentParams): + text_doc = ls.workspace.get_text_document(params.text_document.uri) + ls.publish_diagnostics(text_doc.uri, build_diagnostics(text_doc.uri)) + + server.start_io() diff --git a/tools/chapel-py/chplcheck/rules.py b/tools/chapel-py/chplcheck/rules.py new file mode 100644 index 000000000000..74e22e842f14 --- /dev/null +++ b/tools/chapel-py/chplcheck/rules.py @@ -0,0 +1,138 @@ +# +# Copyright 2020-2023 Hewlett Packard Enterprise Development LP +# Copyright 2004-2019 Cray Inc. +# Other additional copyright holders may be indicated within. +# +# The entirety of this work is licensed under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import chapel +from chapel.core import * +import re + +def name_for_linting(node): + name = node.name() + if name.startswith("chpl_"): + name = name.removeprefix("chpl_") + + # Strip dollar signs. + name = name.replace("$", "") + + return name + +def check_camel_case(node): + return re.fullmatch(r'_?([a-z]+([A-Z][a-z]+|\d+)*|[A-Z]+)?', name_for_linting(node)) + +def check_pascal_case(node): + return re.fullmatch(r'_?(([A-Z][a-z]+|\d+)+|[A-Z]+)?', name_for_linting(node)) + +def register_rules(driver): + @driver.basic_rule(VarLikeDecl) + def CamelCaseVariables(context, node): + if node.name() == "_": return True + return check_camel_case(node) + + @driver.basic_rule(Record) + def CamelCaseRecords(context, node): + return check_camel_case(node) + + @driver.basic_rule(Class) + def PascalCaseClasses(context, node): + return check_pascal_case(node) + + @driver.basic_rule(Module) + def PascalCaseModules(context, node): + return check_pascal_case(node) + + @driver.basic_rule(Loop) + def DoKeywordAndBlock(context, node): + return node.block_style() != "unnecessary" + + @driver.basic_rule(Coforall) + def NestedCoforalls(context, node): + parent = node.parent() + while parent is not None: + if isinstance(parent, Coforall): + return False + parent = parent.parent() + return True + + @driver.basic_rule([Conditional, BoolLiteral, chapel.rest]) + def BoolLitInCondStmt(context, node): + return False + + @driver.basic_rule(NamedDecl) + def ChplPrefixReserved(context, node): + if node.name().startswith("chpl_"): + path = node.location().path() + return context.is_bundled_path(path) + return True + + @driver.advanced_rule + def ConsecutiveDecls(context, root): + def is_relevant_decl(node): + if isinstance(node, MultiDecl): + for child in node: + if isinstance(child, Variable): return child.kind() + elif isinstance(node, Variable): + return node.kind() + return None + + def recurse(node, skip_direct = False): + consecutive = [] + last_kind = None + last_has_attribute = False + + for child in node: + yield from recurse(child, skip_direct = isinstance(child, MultiDecl)) + + if skip_direct: continue + + new_kind = is_relevant_decl(child) + has_attribute = child.attribute_group() is not None + any_has_attribute = last_has_attribute or has_attribute + compatible_kinds = not any_has_attribute and (last_kind is None or last_kind == new_kind) + last_kind = new_kind + last_has_attribute = has_attribute + + # If we ran out of compatible decls, see if we can return them. + if not compatible_kinds: + if len(consecutive) > 1: + yield consecutive[1] + consecutive = [] + + # If this could be a compatible decl, start a new list. + if new_kind is not None: + consecutive.append(child) + + if len(consecutive) > 1: + yield consecutive[1] + + yield from recurse(root) + + @driver.advanced_rule + def MisleadingIndentation(context, root): + prev = None + for child in root: + yield from MisleadingIndentation(context, child) + + if prev is not None: + if child.location().start()[1] == prev.location().start()[1]: + yield child + + if isinstance(child, Loop) and child.block_style() == "implicit": + grandchildren = list(child) + if len(grandchildren) > 0: + prev = list(grandchildren[-1])[0] diff --git a/tools/chapel-py/chplcheck b/tools/chapel-py/runChplcheck similarity index 93% rename from tools/chapel-py/chplcheck rename to tools/chapel-py/runChplcheck index 582eaebba07f..9ae590e7ed8c 100755 --- a/tools/chapel-py/chplcheck +++ b/tools/chapel-py/runChplcheck @@ -26,5 +26,5 @@ if [ -z "$CHPL_HOME" ]; then fi exec $CHPL_HOME/util/config/run-in-venv-with-python-bindings.bash \ - $CHPL_HOME/tools/chapel-py/chplcheck.py "$@" + $CHPL_HOME/tools/chapel-py/chplcheck/__init__.py "$@"