From e4c3bd682145ef2f8e497699b431a49fdf9ead5a Mon Sep 17 00:00:00 2001 From: Vibhu M Date: Sat, 21 Feb 2026 16:28:47 +0530 Subject: [PATCH] fix: warn when markdown link path casing doesn't match actual file on disk --- package-lock.json | 7 +++++- package.json | 4 ++-- src/languageFeatures/diagnostics.ts | 33 +++++++++++++++++++++++++++++ src/test/diagnostic.test.ts | 18 ++++++++++++++++ src/test/util.ts | 1 + 5 files changed, 60 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e66e45d..d29df9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "github-slugger": "^2.0.0", "markdown-it": "^13.0.1", "mkdirp": "^1.0.4", - "mocha": "^10.0.0", + "mocha": "^10.8.2", "source-map-support": "^0.5.21", "typescript": "^5.6.0" }, @@ -539,6 +539,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.1.tgz", "integrity": "sha512-j2VlPv1NnwPJbaCNv69FO/1z4lId0QmGvpT41YxitRtWlg96g/j8qcv2RKsLKe2F6OJgyXhupN1Xo17b2m139Q==", "dev": true, + "peer": true, "dependencies": { "undici-types": "~6.19.2" } @@ -585,6 +586,7 @@ "integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/types": "8.44.1", @@ -825,6 +827,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1282,6 +1285,7 @@ "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2667,6 +2671,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 5711964..c143a24 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "github-slugger": "^2.0.0", "markdown-it": "^13.0.1", "mkdirp": "^1.0.4", - "mocha": "^10.0.0", + "mocha": "^10.8.2", "source-map-support": "^0.5.21", "typescript": "^5.6.0" }, @@ -54,4 +54,4 @@ "bugs": { "url": "https://github.com/microsoft/vscode-markdown-languageservice/issues" } -} \ No newline at end of file +} diff --git a/src/languageFeatures/diagnostics.ts b/src/languageFeatures/diagnostics.ts index 956a0da..316c6a0 100644 --- a/src/languageFeatures/diagnostics.ts +++ b/src/languageFeatures/diagnostics.ts @@ -80,6 +80,12 @@ export interface DiagnosticOptions { * Glob of links that should not be validated. */ readonly ignoreLinks: readonly string[]; + + /** + * If true, validate that link path casing matches the actual file on disk. + * Useful when markdown will be deployed to or shared with case-sensitive systems like GitHub. + */ + readonly validateFileLinksMarkdownCaseSensitive?: boolean; } function toSeverity(level: DiagnosticLevel | undefined): lsp.DiagnosticSeverity | undefined { @@ -113,6 +119,9 @@ export enum DiagnosticCode { /** The link definition is not used anywhere. */ link_duplicateDefinition = 'link.duplicate-definition', + + /** The link file case is mismatched. */ + link_filePathCasingMismatch = 'link.file-path-casing-mismatch', } /** @@ -423,6 +432,30 @@ export class DiagnosticComputer { } } } + else if (options.validateFileLinksMarkdownCaseSensitive !== false) { + const expectedName = path.path.split('/').pop() ?? ''; + const parentUri = path.with({ path: path.path.slice(0, path.path.lastIndexOf('/')) }); + try { + const entries = [...await this.#workspace.readDirectory(parentUri)]; + + const actualEntry = entries.find(([name]) => name.toLowerCase() === expectedName.toLowerCase()); + if (actualEntry && actualEntry[0] !== expectedName) { + for (const link of links) { + if (!this.#isIgnoredLink(options, link.source.hrefPathText)) { + diagnostics.push({ + code: DiagnosticCode.link_filePathCasingMismatch, + message: l10n.t("Path casing mismatch: file is '{0}' but link uses '{1}'. Will break on case-sensitive systems like GitHub.", actualEntry[0], expectedName), + range: link.source.hrefRange, + severity: pathErrorSeverity, + data: { fsPath: path.fsPath, hrefText: link.source.hrefPathText } + }); + } + } + } + } catch { + // readDirectory failed, skip casing check + } + } }); })); return diagnostics; diff --git a/src/test/diagnostic.test.ts b/src/test/diagnostic.test.ts index b9b2703..9098981 100644 --- a/src/test/diagnostic.test.ts +++ b/src/test/diagnostic.test.ts @@ -154,6 +154,24 @@ suite('Diagnostic Computer', () => { const diagnostics = await getComputedDiagnostics(store, doc, workspace); assertDiagnosticsEqual(diagnostics, []); })); + test('Should warn when link path casing does not match actual file', withStore(async (store) => { + const doc = new InMemoryDocument(workspacePath('doc.md'), joinLines( + `[link](docs/Whitepaper.pdf)`, + )); + const workspace = store.add(new InMemoryWorkspace([ + doc, + new InMemoryDocument(workspacePath('docs/whitepaper.pdf'), ''), + ])); + + const diagnostics = await getComputedDiagnostics(store, doc, workspace); + assertDiagnosticsEqual(diagnostics, [ + lsp.Range.create(0, 7, 0, 26), + ]); + })); + // Note: this behavior cannot be fully tested with InMemoryWorkspace since it + // simulates a case-sensitive filesystem. The casing check only triggers on + // macOS/Windows where workspace.stat() succeeds despite path casing mismatches. + // See: https://code.visualstudio.com/api/references/vscode-api (FileSystem) test('Should not generate diagnostics for email autolink', withStore(async (store) => { const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines( diff --git a/src/test/util.ts b/src/test/util.ts index 96c416e..2aa1485 100644 --- a/src/test/util.ts +++ b/src/test/util.ts @@ -84,6 +84,7 @@ export const defaultDiagnosticsOptions = Object.freeze({ validateReferences: DiagnosticLevel.warning, validateUnusedLinkDefinitions: DiagnosticLevel.warning, validateDuplicateLinkDefinitions: DiagnosticLevel.warning, + validateFileLinksMarkdownCaseSensitive: true, ignoreLinks: [], });