index.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. "use strict"
  2. const path = require("path")
  3. const extract = require("./extract")
  4. const utils = require("./utils")
  5. const splatSet = utils.splatSet
  6. const getSettings = require("./settings").getSettings
  7. const getFileMode = require("./getFileMode")
  8. const PREPARE_RULE_NAME = "__eslint-plugin-html-prepare"
  9. const LINTER_ISPATCHED_PROPERTY_NAME =
  10. "__eslint-plugin-html-verify-function-is-patched"
  11. // Disclaimer:
  12. //
  13. // This is not a long term viable solution. ESLint needs to improve its processor API to
  14. // provide access to the configuration before actually preprocess files, but it's not
  15. // planed yet. This solution is quite ugly but shouldn't alter eslint process.
  16. //
  17. // Related github issues:
  18. // https://github.com/eslint/eslint/issues/3422
  19. // https://github.com/eslint/eslint/issues/4153
  20. const needles = [
  21. path.join("lib", "linter", "linter.js"), // ESLint 6+
  22. path.join("lib", "linter.js"), // ESLint 5-
  23. ]
  24. iterateESLintModules(patch)
  25. function getLinterFromModule(moduleExports) {
  26. return moduleExports.Linter
  27. ? moduleExports.Linter // ESLint 6+
  28. : moduleExports // ESLint 5-
  29. }
  30. function getModuleFromRequire() {
  31. return getLinterFromModule(require("eslint/lib/linter"))
  32. }
  33. function getModuleFromCache(key) {
  34. if (!needles.some((needle) => key.endsWith(needle))) return
  35. const module = require.cache[key]
  36. if (!module || !module.exports) return
  37. const Linter = getLinterFromModule(module.exports)
  38. if (
  39. typeof Linter === "function" &&
  40. typeof Linter.prototype.verify === "function"
  41. ) {
  42. return Linter
  43. }
  44. }
  45. function iterateESLintModules(fn) {
  46. if (!require.cache || Object.keys(require.cache).length === 0) {
  47. // Jest is replacing the node "require" function, and "require.cache" isn't available here.
  48. fn(getModuleFromRequire())
  49. return
  50. }
  51. let found = false
  52. for (const key in require.cache) {
  53. const Linter = getModuleFromCache(key)
  54. if (Linter) {
  55. fn(Linter)
  56. found = true
  57. }
  58. }
  59. if (!found) {
  60. let eslintPath, eslintVersion
  61. try {
  62. eslintPath = require.resolve("eslint")
  63. } catch (e) {
  64. eslintPath = "(not found)"
  65. }
  66. try {
  67. eslintVersion = require("eslint/package.json").version
  68. } catch (e) {
  69. eslintVersion = "n/a"
  70. }
  71. const parentPaths = (module) =>
  72. module ? [module.filename].concat(parentPaths(module.parent)) : []
  73. throw new Error(
  74. `eslint-plugin-html error: It seems that eslint is not loaded.
  75. If you think this is a bug, please file a report at https://github.com/BenoitZugmeyer/eslint-plugin-html/issues
  76. In the report, please include *all* those informations:
  77. * ESLint version: ${eslintVersion}
  78. * ESLint path: ${eslintPath}
  79. * Plugin version: ${require("../package.json").version}
  80. * Plugin inclusion paths: ${parentPaths(module).join(", ")}
  81. * NodeJS version: ${process.version}
  82. * CLI arguments: ${JSON.stringify(process.argv)}
  83. * Content of your lock file (package-lock.json or yarn.lock) or the output of \`npm list\`
  84. * How did you run ESLint (via the command line? an editor plugin?)
  85. * The following stack trace:
  86. ${new Error().stack.slice(10)}
  87. `
  88. )
  89. }
  90. }
  91. function patch(Linter) {
  92. const verifyMethodName = Linter.prototype._verifyWithoutProcessors
  93. ? "_verifyWithoutProcessors" // ESLint 6+
  94. : "verify" // ESLint 5-
  95. const verify = Linter.prototype[verifyMethodName]
  96. // ignore if verify function is already been patched sometime before
  97. if (Linter[LINTER_ISPATCHED_PROPERTY_NAME] === true) {
  98. return
  99. }
  100. Linter[LINTER_ISPATCHED_PROPERTY_NAME] = true
  101. Linter.prototype[verifyMethodName] = function (
  102. textOrSourceCode,
  103. config,
  104. filenameOrOptions,
  105. saveState
  106. ) {
  107. const callOriginalVerify = () =>
  108. verify.call(this, textOrSourceCode, config, filenameOrOptions, saveState)
  109. if (typeof config.extractConfig === "function") {
  110. return callOriginalVerify()
  111. }
  112. const pluginSettings = getSettings(config.settings || {})
  113. const mode = getFileMode(pluginSettings, filenameOrOptions)
  114. if (!mode || typeof textOrSourceCode !== "string") {
  115. return callOriginalVerify()
  116. }
  117. let messages
  118. ;[messages, config] = verifyExternalHtmlPlugin(config, callOriginalVerify)
  119. if (config.parser && config.parser.id === "@html-eslint/parser") {
  120. messages.push(...callOriginalVerify())
  121. const rules = {}
  122. for (const name in config.rules) {
  123. if (!name.startsWith("@html-eslint/")) {
  124. rules[name] = config.rules[name]
  125. }
  126. }
  127. config = editConfig(config, {
  128. parser: null,
  129. rules,
  130. })
  131. }
  132. const extractResult = extract(
  133. textOrSourceCode,
  134. pluginSettings.indent,
  135. mode === "xml",
  136. pluginSettings.javaScriptTagNames,
  137. pluginSettings.isJavaScriptMIMEType
  138. )
  139. if (pluginSettings.reportBadIndent) {
  140. messages.push(
  141. ...extractResult.badIndentationLines.map((line) => ({
  142. message: "Bad line indentation.",
  143. line,
  144. column: 1,
  145. ruleId: "(html plugin)",
  146. severity: pluginSettings.reportBadIndent,
  147. }))
  148. )
  149. }
  150. // Save code parts parsed source code so we don't have to parse it twice
  151. const sourceCodes = new WeakMap()
  152. const verifyCodePart = (codePart, { prepare, ignoreRules } = {}) => {
  153. this.defineRule(PREPARE_RULE_NAME, (context) => {
  154. sourceCodes.set(codePart, context.getSourceCode())
  155. return {
  156. Program() {
  157. if (prepare) {
  158. prepare(context)
  159. }
  160. },
  161. }
  162. })
  163. const localMessages = verify.call(
  164. this,
  165. sourceCodes.get(codePart) || String(codePart),
  166. editConfig(config, {
  167. rules: Object.assign(
  168. { [PREPARE_RULE_NAME]: "error" },
  169. !ignoreRules && config.rules
  170. ),
  171. }),
  172. ignoreRules && typeof filenameOrOptions === "object"
  173. ? Object.assign({}, filenameOrOptions, {
  174. reportUnusedDisableDirectives: false,
  175. })
  176. : filenameOrOptions,
  177. saveState
  178. )
  179. messages.push(
  180. ...remapMessages(localMessages, extractResult.hasBOM, codePart)
  181. )
  182. }
  183. const parserOptions = config.parserOptions || {}
  184. if (parserOptions.sourceType === "module") {
  185. for (const codePart of extractResult.code) {
  186. verifyCodePart(codePart)
  187. }
  188. } else {
  189. verifyWithSharedScopes(extractResult.code, verifyCodePart, parserOptions)
  190. }
  191. messages.sort((ma, mb) => ma.line - mb.line || ma.column - mb.column)
  192. return messages
  193. }
  194. }
  195. function editConfig(config, { parser = config.parser, rules = config.rules }) {
  196. return {
  197. ...config,
  198. parser,
  199. rules,
  200. }
  201. }
  202. const externalHtmlPluginPrefixes = [
  203. "@html-eslint/",
  204. "@angular-eslint/template-",
  205. ]
  206. function getParserId(config) {
  207. if (!config.parser) {
  208. return
  209. }
  210. if (typeof config.parser === "string") {
  211. // old versions of ESLint (ex: 4.7)
  212. return config.parser
  213. }
  214. return config.parser.id
  215. }
  216. function verifyExternalHtmlPlugin(config, callOriginalVerify) {
  217. const parserId = getParserId(config)
  218. const externalHtmlPluginPrefix =
  219. parserId &&
  220. externalHtmlPluginPrefixes.find((prefix) => parserId.startsWith(prefix))
  221. if (!externalHtmlPluginPrefix) {
  222. return [[], config]
  223. }
  224. const rules = {}
  225. for (const name in config.rules) {
  226. if (!name.startsWith(externalHtmlPluginPrefix)) {
  227. rules[name] = config.rules[name]
  228. }
  229. }
  230. return [
  231. callOriginalVerify(),
  232. editConfig(config, {
  233. parser: null,
  234. rules,
  235. }),
  236. ]
  237. }
  238. function verifyWithSharedScopes(codeParts, verifyCodePart, parserOptions) {
  239. // First pass: collect needed globals and declared globals for each script tags.
  240. const firstPassValues = []
  241. for (const codePart of codeParts) {
  242. verifyCodePart(codePart, {
  243. prepare(context) {
  244. const globalScope = context.getScope()
  245. // See https://github.com/eslint/eslint/blob/4b267a5c8a42477bb2384f33b20083ff17ad578c/lib/rules/no-redeclare.js#L67-L78
  246. let scopeForDeclaredGlobals
  247. if (
  248. parserOptions.ecmaFeatures &&
  249. parserOptions.ecmaFeatures.globalReturn
  250. ) {
  251. scopeForDeclaredGlobals = globalScope.childScopes[0]
  252. } else {
  253. scopeForDeclaredGlobals = globalScope
  254. }
  255. firstPassValues.push({
  256. codePart,
  257. exportedGlobals: globalScope.through.map(
  258. (node) => node.identifier.name
  259. ),
  260. declaredGlobals: scopeForDeclaredGlobals.variables.map(
  261. (variable) => variable.name
  262. ),
  263. })
  264. },
  265. ignoreRules: true,
  266. })
  267. }
  268. // Second pass: declare variables for each script scope, then run eslint.
  269. for (let i = 0; i < firstPassValues.length; i += 1) {
  270. verifyCodePart(firstPassValues[i].codePart, {
  271. prepare(context) {
  272. const exportedGlobals = splatSet(
  273. firstPassValues
  274. .slice(i + 1)
  275. .map((nextValues) => nextValues.exportedGlobals)
  276. )
  277. for (const name of exportedGlobals) context.markVariableAsUsed(name)
  278. const declaredGlobals = splatSet(
  279. firstPassValues
  280. .slice(0, i)
  281. .map((previousValues) => previousValues.declaredGlobals)
  282. )
  283. const scope = context.getScope()
  284. scope.through = scope.through.filter((variable) => {
  285. return !declaredGlobals.has(variable.identifier.name)
  286. })
  287. },
  288. })
  289. }
  290. }
  291. function remapMessages(messages, hasBOM, codePart) {
  292. const newMessages = []
  293. for (const message of messages) {
  294. if (remapMessage(message, hasBOM, codePart)) {
  295. newMessages.push(message)
  296. }
  297. }
  298. return newMessages
  299. }
  300. function remapMessage(message, hasBOM, codePart) {
  301. if (!message.line || !message.column) {
  302. // Some messages apply to the whole file instead of a particular code location. In particular:
  303. // * @typescript-eslint/parser may send messages with no line/column
  304. // * eslint-plugin-eslint-comments send messages with column=0 to bypass ESLint ignore comments.
  305. // See https://github.com/BenoitZugmeyer/eslint-plugin-html/issues/70
  306. // For now, just include them in the output. In the future, we should make sure those messages
  307. // are not print twice.
  308. return true
  309. }
  310. const location = codePart.originalLocation({
  311. line: message.line,
  312. column: message.column,
  313. })
  314. // Ignore messages if they were in transformed code
  315. if (!location) {
  316. return false
  317. }
  318. Object.assign(message, location)
  319. message.source = codePart.getOriginalLine(location.line)
  320. // Map fix range
  321. if (message.fix && message.fix.range) {
  322. const bomOffset = hasBOM ? -1 : 0
  323. message.fix.range = [
  324. codePart.originalIndex(message.fix.range[0]) + bomOffset,
  325. // The range end is exclusive, meaning it should replace all characters with indexes from
  326. // start to end - 1. We have to get the original index of the last targeted character.
  327. codePart.originalIndex(message.fix.range[1] - 1) + 1 + bomOffset,
  328. ]
  329. }
  330. // Map end location
  331. if (message.endLine && message.endColumn) {
  332. const endLocation = codePart.originalLocation({
  333. line: message.endLine,
  334. column: message.endColumn,
  335. })
  336. if (endLocation) {
  337. message.endLine = endLocation.line
  338. message.endColumn = endLocation.column
  339. }
  340. }
  341. return true
  342. }