1# @file SpellCheck.py
2#
3# An edk2-pytool based plugin wrapper for cspell
4#
5# Copyright (c) Microsoft Corporation.
6# SPDX-License-Identifier: BSD-2-Clause-Patent
7##
8import logging
9import json
10import yaml
11from io import StringIO
12import os
13from edk2toolext.environment.plugintypes.ci_build_plugin import ICiBuildPlugin
14from edk2toollib.utility_functions import RunCmd
15from edk2toolext.environment.var_dict import VarDict
16from edk2toollib.gitignore_parser import parse_gitignore_lines
17from edk2toolext.environment import version_aggregator
18
19
20class SpellCheck(ICiBuildPlugin):
21    """
22    A CiBuildPlugin that uses the cspell node module to scan the files
23    from the package being tested for spelling errors.  The plugin contains
24    the base cspell.json file then thru the configuration options other settings
25    can be changed or extended.
26
27    Configuration options:
28    "SpellCheck": {
29        "AuditOnly": False,          # Don't fail the build if there are errors.  Just log them
30        "IgnoreFiles": [],           # use gitignore syntax to ignore errors in matching files
31        "ExtendWords": [],           # words to extend to the dictionary for this package
32        "IgnoreStandardPaths": [],   # Standard Plugin defined paths that should be ignore
33        "AdditionalIncludePaths": [] # Additional paths to spell check (wildcards supported)
34    }
35    """
36
37    #
38    # A package can remove any of these using IgnoreStandardPaths
39    #
40    STANDARD_PLUGIN_DEFINED_PATHS = ["*.c", "*.h",
41                                     "*.nasm", "*.asm", "*.masm", "*.s",
42                                     "*.asl",
43                                     "*.dsc", "*.dec", "*.fdf", "*.inf",
44                                     "*.md", "*.txt"
45                                     ]
46
47    def GetTestName(self, packagename: str, environment: VarDict) -> tuple:
48        """ Provide the testcase name and classname for use in reporting
49
50            Args:
51              packagename: string containing name of package to build
52              environment: The VarDict for the test to run in
53            Returns:
54                a tuple containing the testcase name and the classname
55                (testcasename, classname)
56                testclassname: a descriptive string for the testcase can include whitespace
57                classname: should be patterned <packagename>.<plugin>.<optionally any unique condition>
58        """
59        return ("Spell check files in " + packagename, packagename + ".SpellCheck")
60
61    ##
62    # External function of plugin.  This function is used to perform the task of the CiBuild Plugin
63    #
64    #   - package is the edk2 path to package.  This means workspace/packagepath relative.
65    #   - edk2path object configured with workspace and packages path
66    #   - PkgConfig Object (dict) for the pkg
67    #   - EnvConfig Object
68    #   - Plugin Manager Instance
69    #   - Plugin Helper Obj Instance
70    #   - Junit Logger
71    #   - output_stream the StringIO output stream from this plugin via logging
72
73    def RunBuildPlugin(self, packagename, Edk2pathObj, pkgconfig, environment, PLM, PLMHelper, tc, output_stream=None):
74        Errors = []
75
76        abs_pkg_path = Edk2pathObj.GetAbsolutePathOnThisSytemFromEdk2RelativePath(
77            packagename)
78
79        if abs_pkg_path is None:
80            tc.SetSkipped()
81            tc.LogStdError("No package {0}".format(packagename))
82            return -1
83
84        # check for node
85        return_buffer = StringIO()
86        ret = RunCmd("node", "--version", outstream=return_buffer)
87        if (ret != 0):
88            tc.SetSkipped()
89            tc.LogStdError("NodeJs not installed. Test can't run")
90            logging.warning("NodeJs not installed. Test can't run")
91            return -1
92        node_version = return_buffer.getvalue().strip()  # format vXX.XX.XX
93        tc.LogStdOut(f"Node version: {node_version}")
94        version_aggregator.GetVersionAggregator().ReportVersion(
95            "NodeJs", node_version, version_aggregator.VersionTypes.INFO)
96
97        # Check for cspell
98        return_buffer = StringIO()
99        ret = RunCmd("cspell", "--version", outstream=return_buffer)
100        if (ret != 0):
101            tc.SetSkipped()
102            tc.LogStdError("cspell not installed.  Test can't run")
103            logging.warning("cspell not installed.  Test can't run")
104            return -1
105        cspell_version = return_buffer.getvalue().strip()  # format XX.XX.XX
106        tc.LogStdOut(f"CSpell version: {cspell_version}")
107        version_aggregator.GetVersionAggregator().ReportVersion(
108            "CSpell", cspell_version, version_aggregator.VersionTypes.INFO)
109
110        package_relative_paths_to_spell_check = SpellCheck.STANDARD_PLUGIN_DEFINED_PATHS
111
112        #
113        # Allow the ci.yaml to remove any of the above standard paths
114        #
115        if("IgnoreStandardPaths" in pkgconfig):
116            for a in pkgconfig["IgnoreStandardPaths"]:
117                if(a in package_relative_paths_to_spell_check):
118                    tc.LogStdOut(
119                        f"ignoring standard path due to ci.yaml ignore: {a}")
120                    package_relative_paths_to_spell_check.remove(a)
121                else:
122                    tc.LogStdOut(f"Invalid IgnoreStandardPaths value: {a}")
123
124        #
125        # check for any additional include paths defined by package config
126        #
127        if("AdditionalIncludePaths" in pkgconfig):
128            package_relative_paths_to_spell_check.extend(
129                pkgconfig["AdditionalIncludePaths"])
130
131        #
132        # Make the path string for cspell to check
133        #
134        relpath = os.path.relpath(abs_pkg_path)
135        cpsell_paths = " ".join(
136            [f"{relpath}/**/{x}" for x in package_relative_paths_to_spell_check])
137
138        # Make the config file
139        config_file_path = os.path.join(
140            Edk2pathObj.WorkspacePath, "Build", packagename, "cspell_actual_config.json")
141        mydir = os.path.dirname(os.path.abspath(__file__))
142        # load as yaml so it can have comments
143        base = os.path.join(mydir, "cspell.base.yaml")
144        with open(base, "r") as i:
145            config = yaml.safe_load(i)
146
147        if("ExtendWords" in pkgconfig):
148            config["words"].extend(pkgconfig["ExtendWords"])
149        with open(config_file_path, "w") as o:
150            json.dump(config, o)  # output as json so compat with cspell
151
152        All_Ignores = []
153        # Parse the config for other ignores
154        if "IgnoreFiles" in pkgconfig:
155            All_Ignores.extend(pkgconfig["IgnoreFiles"])
156
157        # spell check all the files
158        ignore = parse_gitignore_lines(All_Ignores, os.path.join(
159            abs_pkg_path, "nofile.txt"), abs_pkg_path)
160
161        # result is a list of strings like this
162        #  C:\src\sp-edk2\edk2\FmpDevicePkg\FmpDevicePkg.dec:53:9 - Unknown word (Capule)
163        EasyFix = []
164        results = self._check_spelling(cpsell_paths, config_file_path)
165        for r in results:
166            path, _, word = r.partition(" - Unknown word ")
167            if len(word) == 0:
168                # didn't find pattern
169                continue
170
171            pathinfo = path.rsplit(":", 2)  # remove the line no info
172            if(ignore(pathinfo[0])):  # check against ignore list
173                tc.LogStdOut(f"ignoring error due to ci.yaml ignore: {r}")
174                continue
175
176            # real error
177            EasyFix.append(word.strip().strip("()"))
178            Errors.append(r)
179
180        # Log all errors tc StdError
181        for l in Errors:
182            tc.LogStdError(l.strip())
183
184        # Helper - Log the syntax needed to add these words to dictionary
185        if len(EasyFix) > 0:
186            EasyFix = sorted(set(a.lower() for a in EasyFix))
187            tc.LogStdOut("\n Easy fix:")
188            OneString = "If these are not errors add this to your ci.yaml file.\n"
189            OneString += '"SpellCheck": {\n  "ExtendWords": ['
190            for a in EasyFix:
191                tc.LogStdOut(f'\n"{a}",')
192                OneString += f'\n    "{a}",'
193            logging.info(OneString.rstrip(",") + '\n  ]\n}')
194
195        # add result to test case
196        overall_status = len(Errors)
197        if overall_status != 0:
198            if "AuditOnly" in pkgconfig and pkgconfig["AuditOnly"]:
199                # set as skipped if AuditOnly
200                tc.SetSkipped()
201                return -1
202            else:
203                tc.SetFailed("SpellCheck {0} Failed.  Errors {1}".format(
204                    packagename, overall_status), "CHECK_FAILED")
205        else:
206            tc.SetSuccess()
207        return overall_status
208
209    def _check_spelling(self, abs_file_to_check: str, abs_config_file_to_use: str) -> []:
210        output = StringIO()
211        ret = RunCmd(
212            "cspell", f"--config {abs_config_file_to_use} {abs_file_to_check}", outstream=output)
213        if ret == 0:
214            return []
215        else:
216            return output.getvalue().strip().splitlines()
217