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