Skip to content

Commit

Permalink
Merge pull request #132 from mjbvz/dev/mjbvz/html-path-completions
Browse files Browse the repository at this point in the history
0.4.0-alpha 2
  • Loading branch information
mjbvz authored May 23, 2023
2 parents a58bcba + e6e86d1 commit 5552f71
Show file tree
Hide file tree
Showing 7 changed files with 159 additions and 31 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

## 0.4.0-alpha.2 — May 23, 2023
- Add path completions in HTML attributes

## 0.4.0-alpha.1 — May 2, 2023
- Enable document links, references, and rename for HTML fragments in Markdown.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "vscode-markdown-languageservice",
"description": "Markdown language service",
"version": "0.4.0-alpha.1",
"version": "0.4.0-alpha.2",
"author": "Microsoft Corporation",
"license": "MIT",
"engines": {
Expand Down
21 changes: 13 additions & 8 deletions src/languageFeatures/documentLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,16 @@ class NoLinkRanges {
}
}

/**
* Map of html tags to attributes that contain links.
*/
export const htmlTagPathAttrs = new Map([
['IMG', ['src']],
['VIDEO', ['src', 'placeholder']],
['SOURCE', ['src']],
['A', ['href']],
]);

/**
* The place a document link links to.
*/
Expand Down Expand Up @@ -513,12 +523,12 @@ export class MdLinkComputer {
const text = match[3];
if (!text) {
// Handle the case ![][cat]
if (!match[0].startsWith('!')) {
if (!match[2].startsWith('!')) {
// Empty links are not valid
continue;
}
}
if (!match[0].startsWith('!')) {
if (!match[2].startsWith('!')) {
// Also get links in text
yield* this.#getReferenceLinksInText(document, match[3], linkStartOffset + 1, noLinkRanges);
}
Expand Down Expand Up @@ -633,12 +643,7 @@ export class MdLinkComputer {
return { attr, regexp: new RegExp(`(${attr}=["'])([^'"]*)["']`, 'i') };
}

static readonly #linkAttrsByTag = new Map([
['IMG', ['src'].map(this.#toAttrEntry)],
['VIDEO', ['src', 'placeholder'].map(this.#toAttrEntry)],
['SOURCE', ['src'].map(this.#toAttrEntry)],
['A', ['href'].map(this.#toAttrEntry)],
]);
static readonly #linkAttrsByTag = new Map(Array.from(htmlTagPathAttrs.entries(), ([key, value]) => [key, value.map(MdLinkComputer.#toAttrEntry)]));

*#getHtmlLinksFromNode(document: ITextDocument, node: HTMLElement, noLinkRanges: NoLinkRanges): Iterable<MdLink> {
const attrs = MdLinkComputer.#linkAttrsByTag.get(node.tagName);
Expand Down
61 changes: 41 additions & 20 deletions src/languageFeatures/pathCompletions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { Schemes } from '../util/schemes';
import { r } from '../util/string';
import { FileStat, getWorkspaceFolder, IWorkspace, openLinkToMarkdownFile } from '../workspace';
import { MdWorkspaceInfoCache } from '../workspaceCache';
import { MdLinkProvider } from './documentLinks';
import { MdLinkProvider, htmlTagPathAttrs } from './documentLinks';

enum CompletionContextKind {
/** `[...](|)` */
Expand All @@ -31,6 +31,9 @@ enum CompletionContextKind {

/** `[]: |` */
LinkDefinition,

/** <img src="|"> */
HtmlAttribute,
}

interface AnchorContext {
Expand Down Expand Up @@ -181,7 +184,8 @@ export class MdPathCompletionProvider {
return;
}
case CompletionContextKind.LinkDefinition:
case CompletionContextKind.Link: {
case CompletionContextKind.Link:
case CompletionContextKind.HtmlAttribute: {
if (
(context.linkPrefix.startsWith('#') && options.includeWorkspaceHeaderCompletions === IncludeWorkspaceHeaderCompletions.onSingleOrDoubleHash) ||
(context.linkPrefix.startsWith('##') && (options.includeWorkspaceHeaderCompletions === IncludeWorkspaceHeaderCompletions.onDoubleHash || options.includeWorkspaceHeaderCompletions === IncludeWorkspaceHeaderCompletions.onSingleOrDoubleHash))
Expand Down Expand Up @@ -222,6 +226,7 @@ export class MdPathCompletionProvider {
yield* this.#providePathSuggestions(document, position, context, token);
}
}
return;
}
}
}
Expand All @@ -247,12 +252,31 @@ export class MdPathCompletionProvider {
/// [id]: |
readonly #definitionPattern = /^\s*\[[\w\-]+\]:\s*([^\s]*)$/m;

/// [id]: |
readonly #htmlAttributeStartPattern = /\<(?<tag>\w+)([^>]*)\s(?<attr>\w+)=['"](?<link>[^'"]*)$/m;

#getPathCompletionContext(document: ITextDocument, position: lsp.Position): PathCompletionContext | undefined {
const line = getLine(document, position.line);

const linePrefixText = line.slice(0, position.character);
const lineSuffixText = line.slice(position.character);

const htmlAttributePrefixMatch = linePrefixText.match(this.#htmlAttributeStartPattern);
if (htmlAttributePrefixMatch?.groups) {
const validPathAttrs = htmlTagPathAttrs.get(htmlAttributePrefixMatch.groups.tag.toUpperCase());
if (!validPathAttrs || !validPathAttrs.some(attr => attr === htmlAttributePrefixMatch.groups!.attr.toLowerCase())) {
return undefined;
}

const prefix = htmlAttributePrefixMatch.groups.link;
if (this.#refLooksLikeUrl(prefix)) {
return undefined;
}

const suffix = lineSuffixText.match(/^[^\s'"]*/);
return this.#createCompletionContext(CompletionContextKind.HtmlAttribute, position, prefix, suffix?.[0] ?? '', false);
}

const linkPrefixMatch = linePrefixText.match(this.#linkStartPattern);
if (linkPrefixMatch) {
const isAngleBracketLink = linkPrefixMatch[1].startsWith('<');
Expand All @@ -262,14 +286,7 @@ export class MdPathCompletionProvider {
}

const suffix = lineSuffixText.match(/^[^\)\s][^\)\s\>]*/);
return {
kind: CompletionContextKind.Link,
linkPrefix: tryDecodeUriComponent(prefix),
linkTextStartPosition: translatePosition(position, { characterDelta: -prefix.length }),
linkSuffix: suffix ? suffix[0] : '',
anchorInfo: this.#getAnchorContext(prefix),
skipEncoding: isAngleBracketLink,
};
return this.#createCompletionContext(CompletionContextKind.Link, position, prefix, suffix?.[0] ?? '', isAngleBracketLink);
}

const definitionLinkPrefixMatch = linePrefixText.match(this.#definitionPattern);
Expand All @@ -281,14 +298,7 @@ export class MdPathCompletionProvider {
}

const suffix = lineSuffixText.match(/^[^\s]*/);
return {
kind: CompletionContextKind.LinkDefinition,
linkPrefix: tryDecodeUriComponent(prefix),
linkTextStartPosition: translatePosition(position, { characterDelta: -prefix.length }),
linkSuffix: suffix ? suffix[0] : '',
anchorInfo: this.#getAnchorContext(prefix),
skipEncoding: isAngleBracketLink,
};
return this.#createCompletionContext(CompletionContextKind.LinkDefinition, position, prefix, suffix?.[0] ?? '', isAngleBracketLink);
}

const referenceLinkPrefixMatch = linePrefixText.match(this.#referenceLinkStartPattern);
Expand All @@ -306,6 +316,17 @@ export class MdPathCompletionProvider {
return undefined;
}

#createCompletionContext(kind: CompletionContextKind, position: lsp.Position, prefix: string, suffix: string, skipEncoding: boolean): PathCompletionContext | undefined {
return {
kind,
linkPrefix: tryDecodeUriComponent(prefix),
linkTextStartPosition: translatePosition(position, { characterDelta: -prefix.length }),
linkSuffix: suffix,
anchorInfo: this.#getAnchorContext(prefix),
skipEncoding,
};
}

/**
* Check if {@param ref} looks like a 'http:' style url.
*/
Expand Down Expand Up @@ -379,7 +400,7 @@ export class MdPathCompletionProvider {
}

#ownHeaderEntryDetails(entry: TocEntry): string | undefined {
return l10n.t(`Link to '{0}'`, '#'.repeat(entry.level) + ' ' +entry.text);
return l10n.t(`Link to '{0}'`, '#'.repeat(entry.level) + ' ' + entry.text);
}

/**
Expand All @@ -406,7 +427,7 @@ export class MdPathCompletionProvider {
const completionItem = this.#createHeaderCompletion(entry, insertionRange, replacementRange, path);
completionItem.filterText = '#' + completionItem.label;
completionItem.sortText = isHeaderInCurrentDocument ? sortTexts.localHeader : sortTexts.workspaceHeader;

if (isHeaderInCurrentDocument) {
completionItem.detail = this.#ownHeaderEntryDetails(entry);
} else if (path) {
Expand Down
16 changes: 16 additions & 0 deletions src/test/diagnostic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,22 @@ suite('Diagnostic Computer', () => {
makeRange(2, 1, 2, 4),
]);
}));

test('Should not mark image reference as unused (#131)', withStore(async (store) => {
const docUri = workspacePath('doc.md');
const doc = new InMemoryDocument(docUri, joinLines(
`text`,
``,
`![][cat]`,
``,
`[cat]: https://example.com/cat.png`,
));

const workspace = store.add(new InMemoryWorkspace([doc]));

const diagnostics = await getComputedDiagnostics(store, doc, workspace, { });
assertDiagnosticsEqual(diagnostics, []);
}));
});


Expand Down
7 changes: 7 additions & 0 deletions src/test/documentLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,13 @@ suite('Link computer', () => {
]);
});

test('Should find inline image reference links', async () => {
const links = await getLinksForText('ab ![][cat] d');
assertLinksEqual(links, [
makeRange(0, 7, 0, 10),
]);
});

test('Should not consider link references starting with ^ character valid (#107471)', async () => {
const links = await getLinksForText('[^reference]: https://example.com');
assertLinksEqual(links, []);
Expand Down
80 changes: 78 additions & 2 deletions src/test/pathCompletion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,8 +509,8 @@ suite('Path completions', () => {
new InMemoryDocument(workspacePath('a.md'), joinLines(
'# A b C',
)),
new InMemoryDocument(workspacePath('sub','c.md'), joinLines(
'# x Y z',
new InMemoryDocument(workspacePath('sub', 'c.md'), joinLines(
'# x Y z',
)),
]));

Expand Down Expand Up @@ -583,4 +583,80 @@ suite('Path completions', () => {
]);
}));
});

suite('Html attribute path completions', () => {

test('Should return completions for headers in current doc', withStore(async (store) => {
const completions = await getCompletionsAtCursorForFileContents(store, workspacePath('new.md'), joinLines(
`# a B c`,
``,
`<img src="${CURSOR}`,
));

assertCompletionsEqual(completions, [
{ label: '#a-b-c' },
{ label: 'new.md' },
]);
}));

test('Should not return completions on unknown tags or attributes', withStore(async (store) => {
{
const completions = await getCompletionsAtCursorForFileContents(store, workspacePath('new.md'), joinLines(
`# a B c`,
``,
`<img source="${CURSOR}`,
));
assertCompletionsEqual(completions, []);
}
{
const completions = await getCompletionsAtCursorForFileContents(store, workspacePath('new.md'), joinLines(
`# a B c`,
``,
`<image src="${CURSOR}`,
));
assertCompletionsEqual(completions, []);
}
}));

test('Should not return completions for links with scheme', withStore(async (store) => {
{
const completions = await getCompletionsAtCursorForFileContents(store, workspacePath('new.md'), joinLines(
`# a B c`,
``,
`<img src="http://${CURSOR}`,
));

assertCompletionsEqual(completions, []);
}
{
const completions = await getCompletionsAtCursorForFileContents(store, workspacePath('new.md'), joinLines(
`# a B c`,
``,
`<img src="mailto:${CURSOR}`,
));

assertCompletionsEqual(completions, []);
}
}));

test('Should return completions when other attributes are present', withStore(async (store) => {
const completions = await getCompletionsAtCursorForFileContents(store, workspacePath('new.md'), joinLines(
`<img style="color: red" src="${CURSOR}" height="10px">`,
));

assertCompletionsEqual(completions, [
{ label: 'new.md' },
]);
}));

test('Should return completions for inline html', withStore(async (store) => {
const completions = await getCompletionsAtCursorForFileContents(store, workspacePath('new.md'), joinLines(
`some text <img src="./${CURSOR}"> more text`,
));

assertCompletionsEqual(completions, [
{ label: 'new.md' },
]);
}));
});
});

0 comments on commit 5552f71

Please sign in to comment.