|
| 1 | +import fs from 'node:fs'; |
| 2 | +import path from 'node:path'; |
| 3 | +import { createRule } from '../utils/index.js'; |
| 4 | + |
| 5 | +// Extensions that plain TypeScript resolution appends to an unknown `.svelte` |
| 6 | +// specifier. `.d.ts` is intentionally excluded: `Foo.svelte.d.ts` is a |
| 7 | +// hand-written component declaration and does not shadow the component. |
| 8 | +const MODULE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs']; |
| 9 | + |
| 10 | +function isFile(filePath: string): boolean { |
| 11 | + try { |
| 12 | + return fs.statSync(filePath).isFile(); |
| 13 | + } catch { |
| 14 | + return false; |
| 15 | + } |
| 16 | +} |
| 17 | + |
| 18 | +// Resolve the real on-disk casing of a path's basename. On case-insensitive |
| 19 | +// file systems (macOS, Windows) `fs.statSync` matches a differently-cased |
| 20 | +// sibling, so the constructed name may not exist with that exact casing. Read |
| 21 | +// the directory and use the actual entry name when one matches. |
| 22 | +function realBasename(filePath: string): string { |
| 23 | + const wanted = path.basename(filePath); |
| 24 | + try { |
| 25 | + const entries = fs.readdirSync(path.dirname(filePath)); |
| 26 | + return ( |
| 27 | + entries.find((entry) => entry === wanted) ?? |
| 28 | + entries.find((entry) => entry.toLowerCase() === wanted.toLowerCase()) ?? |
| 29 | + wanted |
| 30 | + ); |
| 31 | + } catch { |
| 32 | + return wanted; |
| 33 | + } |
| 34 | +} |
| 35 | + |
| 36 | +export default createRule('no-conflicting-module-names', { |
| 37 | + meta: { |
| 38 | + docs: { |
| 39 | + description: |
| 40 | + 'disallow a `.svelte` component and a same-named runes module (e.g. `Foo.svelte` and `Foo.svelte.ts`) from coexisting', |
| 41 | + category: 'Possible Errors', |
| 42 | + recommended: false |
| 43 | + }, |
| 44 | + schema: [], |
| 45 | + messages: { |
| 46 | + conflictOnComponent: |
| 47 | + 'The module `{{moduleName}}` has the same name as this component. TypeScript resolves the import `{{specifier}}` to that module, not to this component. Rename `{{moduleName}}`.', |
| 48 | + conflictOnModule: |
| 49 | + 'This module has the same name as the component `{{svelteName}}`. TypeScript resolves the import `{{specifier}}` to this module, not to the component. Rename this file.' |
| 50 | + }, |
| 51 | + type: 'problem' |
| 52 | + }, |
| 53 | + create(context) { |
| 54 | + const filename = context.physicalFilename; |
| 55 | + |
| 56 | + // Skip virtual/untitled files that do not exist on disk (editors, tests |
| 57 | + // with fake paths). Only real files on disk are checked. |
| 58 | + if (!isFile(filename)) { |
| 59 | + return {}; |
| 60 | + } |
| 61 | + |
| 62 | + // Both sides of the collision are linted by this plugin, and a lint run |
| 63 | + // may contain only one of them, so each side reports on itself. |
| 64 | + if (filename.endsWith('.svelte')) { |
| 65 | + return { |
| 66 | + Program(node) { |
| 67 | + const svelteName = path.basename(filename); |
| 68 | + for (const ext of MODULE_EXTENSIONS) { |
| 69 | + const modulePath = `${filename}${ext}`; |
| 70 | + if (isFile(modulePath)) { |
| 71 | + context.report({ |
| 72 | + node, |
| 73 | + loc: { line: 1, column: 0 }, |
| 74 | + messageId: 'conflictOnComponent', |
| 75 | + data: { |
| 76 | + moduleName: realBasename(modulePath), |
| 77 | + specifier: `./${svelteName}` |
| 78 | + } |
| 79 | + }); |
| 80 | + return; |
| 81 | + } |
| 82 | + } |
| 83 | + } |
| 84 | + }; |
| 85 | + } |
| 86 | + |
| 87 | + // A module named `<name>.svelte.<ext>`. Dropping the extension gives the |
| 88 | + // component path it collides with. Declaration files such as |
| 89 | + // `Foo.svelte.d.ts` do not match, because dropping `.ts` leaves |
| 90 | + // `Foo.svelte.d`, which is not a component name. |
| 91 | + const moduleExt = MODULE_EXTENSIONS.find((ext) => filename.endsWith(ext)); |
| 92 | + if (moduleExt == null) { |
| 93 | + return {}; |
| 94 | + } |
| 95 | + const sveltePath = filename.slice(0, -moduleExt.length); |
| 96 | + if (!sveltePath.endsWith('.svelte') || !isFile(sveltePath)) { |
| 97 | + return {}; |
| 98 | + } |
| 99 | + |
| 100 | + return { |
| 101 | + Program(node) { |
| 102 | + const svelteName = realBasename(sveltePath); |
| 103 | + context.report({ |
| 104 | + node, |
| 105 | + loc: { line: 1, column: 0 }, |
| 106 | + messageId: 'conflictOnModule', |
| 107 | + data: { |
| 108 | + svelteName, |
| 109 | + specifier: `./${svelteName}` |
| 110 | + } |
| 111 | + }); |
| 112 | + } |
| 113 | + }; |
| 114 | + } |
| 115 | +}); |
0 commit comments