Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -54,4 +54,4 @@
"bugs": {
"url": "https://github.com/microsoft/vscode-markdown-languageservice/issues"
}
}
}
33 changes: 33 additions & 0 deletions src/languageFeatures/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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',
}

/**
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions src/test/diagnostic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/test/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export const defaultDiagnosticsOptions = Object.freeze<DiagnosticOptions>({
validateReferences: DiagnosticLevel.warning,
validateUnusedLinkDefinitions: DiagnosticLevel.warning,
validateDuplicateLinkDefinitions: DiagnosticLevel.warning,
validateFileLinksMarkdownCaseSensitive: true,
ignoreLinks: [],
});

Expand Down