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
14 changes: 2 additions & 12 deletions language-server/src/features/Hover.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { MarkupKind } from "vscode-languageserver";
import { JsonDocuments } from "../services/JsonDocuments.ts";
import { LspEvaluationPlugin } from "../evaluation/LspEvaluationPlugin.ts";
import { describeAnnotations } from "../util/utils.ts";

import type { Server } from "../services/Server.ts";
import type { ServerCapabilities } from "vscode-languageserver";
Expand Down Expand Up @@ -37,18 +38,7 @@ export class Hover {
? plugin.getLocationAnnotations(pointer)
: [...plugin.getLocationAnnotations(pointer), ...plugin.getValueAnnotations(pointer)];

const lines: string[] = [];
for (const annotation of annotations) {
const title = annotation.title();
if (title) {
lines.push(`**${title}**`);
}
const description = annotation.description();
if (description) {
lines.push(description);
}
}

const lines = describeAnnotations(annotations);
if (lines.length === 0) {
return;
}
Expand Down
31 changes: 20 additions & 11 deletions language-server/src/features/completions/Completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as JsonPointer from "@hyperjump/json-pointer";
import * as Pact from "@hyperjump/pact";
import { JsonDocuments } from "../../services/JsonDocuments.ts";
import { JsonDocument } from "../../models/JsonDocument.ts";
import { describeAnnotations } from "../../util/utils.ts";

import type { CompletionItem, MarkupContent, Position, ServerCapabilities, Range } from "vscode-languageserver";
import type { Node } from "jsonc-parser";
Expand Down Expand Up @@ -116,21 +117,28 @@ const getCompletionContext = (jsonDocument: JsonDocument, position: Position): C
return { node, instanceLocation, range, position };
};

// Marks a completion as deprecated if any of the annotations deprecate it,
// with their messages as its documentation
export const tagDeprecated = (completionItem: CompletionItem, annotations: Annotation[]) => {
// Describes a completion with its annotations. It's marked as deprecated if any
// of them deprecate it, and its documentation leads with their deprecation
// messages followed by the titles and descriptions.
export const describeCompletion = (completionItem: CompletionItem, annotations: Annotation[]) => {
const deprecated = annotations.filter((annotation) => annotation.deprecated());
if (deprecated.length > 0) {
completionItem.tags = [CompletionItemTag.Deprecated];
completionItem.documentation = deprecationDocumentation(deprecated);
}

const documentation = getDocumentation(deprecated, describeAnnotations(annotations));
if (documentation) {
completionItem.documentation = documentation;
}
};

// The deprecation messages of every annotation that deprecates a completion.
// Markdown if any of them is markdown, otherwise plain text. Plain text
// messages are escaped when they're combined with markdown ones.
const deprecationDocumentation = (deprecated: Annotation[]): MarkupContent | undefined => {
const isMarkdown = deprecated.some((annotation) => annotation.markdownDeprecationMessage() !== undefined);
// The deprecation messages of every annotation that deprecates a completion,
// followed by the titles and descriptions. Markdown if any of them is markdown,
// otherwise plain text. Plain text messages are escaped when they're combined
// with markdown.
const getDocumentation = (deprecated: Annotation[], descriptions: string[]): MarkupContent | undefined => {
const isMarkdown = descriptions.length > 0
|| deprecated.some((annotation) => annotation.markdownDeprecationMessage() !== undefined);
const messages = Pact.pipe(
deprecated,
Pact.map((annotation) => {
Expand All @@ -141,11 +149,12 @@ const deprecationDocumentation = (deprecated: Annotation[]): MarkupContent | und
Pact.collectArray
);

if (messages.length === 0) {
const lines = [...messages, ...descriptions];
if (lines.length === 0) {
return;
}

return { kind: isMarkdown ? MarkupKind.Markdown : MarkupKind.PlainText, value: messages.join("\n\n") };
return { kind: isMarkdown ? MarkupKind.Markdown : MarkupKind.PlainText, value: lines.join("\n\n") };
};

// Any ASCII punctuation can be escaped, so escaping all of it is always safe
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { CompletionItem, CompletionItemKind, CompletionItemTag, CompletionRequest } from "vscode-languageserver";
import { CompletionItem, CompletionItemKind, CompletionItemTag, CompletionRequest, MarkupKind } from "vscode-languageserver";
import { TestClient } from "../../test/TestClient.ts";

describe("Property completions", () => {
Expand Down Expand Up @@ -1203,6 +1203,191 @@ describe("Property completions", () => {
]);
});

test("completion is documented with the title and description of the property", async () => {
fixtureSchemaUri = await client.writeDocument("schema.json", `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": { "title": "Name", "description": "The name of the thing" }
}
}`);

await client.writeDocument("instance.json", `{
"$schema": "${fixtureSchemaUri}",
""
}`);
const uri = await client.openDocument("instance.json");

const completions = await client.sendRequest(CompletionRequest.type, {
textDocument: { uri },
position: { line: 2, character: 7 }
});

expect(completions).toMatchObject([
{ label: "name", documentation: { kind: MarkupKind.Markdown, value: "**Name**\n\nThe name of the thing" } }
]);
});

test("completion is documented with only a title", async () => {
fixtureSchemaUri = await client.writeDocument("schema.json", `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": { "title": "Name" }
}
}`);

await client.writeDocument("instance.json", `{
"$schema": "${fixtureSchemaUri}",
""
}`);
const uri = await client.openDocument("instance.json");

const completions = await client.sendRequest(CompletionRequest.type, {
textDocument: { uri },
position: { line: 2, character: 7 }
});

expect(completions).toMatchObject([
{ label: "name", documentation: { kind: MarkupKind.Markdown, value: "**Name**" } }
]);
});

test("completion is documented with only a description", async () => {
fixtureSchemaUri = await client.writeDocument("schema.json", `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": { "description": "The name of the thing" }
}
}`);

await client.writeDocument("instance.json", `{
"$schema": "${fixtureSchemaUri}",
""
}`);
const uri = await client.openDocument("instance.json");

const completions = await client.sendRequest(CompletionRequest.type, {
textDocument: { uri },
position: { line: 2, character: 7 }
});

expect(completions).toMatchObject([
{ label: "name", documentation: { kind: MarkupKind.Markdown, value: "The name of the thing" } }
]);
});

test("completion is documented with every title and description that applies to the property", async () => {
fixtureSchemaUri = await client.writeDocument("schema.json", `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": {
"allOf": [
{ "title": "A", "description": "The first" },
{ "title": "B" }
]
}
}
}`);

await client.writeDocument("instance.json", `{
"$schema": "${fixtureSchemaUri}",
""
}`);
const uri = await client.openDocument("instance.json");

const completions = await client.sendRequest(CompletionRequest.type, {
textDocument: { uri },
position: { line: 2, character: 7 }
});

expect(completions).toMatchObject([
{ label: "name", documentation: { kind: MarkupKind.Markdown, value: "**A**\n\nThe first\n\n**B**" } }
]);
});

test("completion documentation prefers markdownDescription over description", async () => {
fixtureSchemaUri = await client.writeDocument("schema.json", `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": { "description": "Plain", "markdownDescription": "_Markdown_" }
}
}`);

await client.writeDocument("instance.json", `{
"$schema": "${fixtureSchemaUri}",
""
}`);
const uri = await client.openDocument("instance.json");

const completions = await client.sendRequest(CompletionRequest.type, {
textDocument: { uri },
position: { line: 2, character: 7 }
});

expect(completions).toMatchObject([
{ label: "name", documentation: { kind: MarkupKind.Markdown, value: "_Markdown_" } }
]);
});

test("completion is not documented with annotations that only apply to some values", async () => {
fixtureSchemaUri = await client.writeDocument("schema.json", `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": {
"title": "Name",
"anyOf": [
{ "const": "a", "title": "A" },
{ "const": "b", "title": "B" }
]
}
}
}`);

await client.writeDocument("instance.json", `{
"$schema": "${fixtureSchemaUri}",
""
}`);
const uri = await client.openDocument("instance.json");

const completions = await client.sendRequest(CompletionRequest.type, {
textDocument: { uri },
position: { line: 2, character: 7 }
});

expect(completions).toMatchObject([
{ label: "name", documentation: { kind: MarkupKind.Markdown, value: "**Name**" } }
]);
});

test("completion is not documented when the property has no title or description", async () => {
fixtureSchemaUri = await client.writeDocument("schema.json", `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": { "type": "string" }
}
}`);

await client.writeDocument("instance.json", `{
"$schema": "${fixtureSchemaUri}",
""
}`);
const uri = await client.openDocument("instance.json");

const completions = await client.sendRequest(CompletionRequest.type, {
textDocument: { uri },
position: { line: 2, character: 7 }
});

expect(completions).toHaveLength(1);
expect((completions as CompletionItem[])[0]).not.toHaveProperty("documentation");
});

test("completion returns no completions when the schema can't be loaded", async () => {
await client.writeDocument("instance.json", `{
"$schema": "./missing.json",
Expand Down Expand Up @@ -1387,5 +1572,85 @@ describe("Property completions", () => {
{ label: "name", documentation: { kind: "markdown", value: "Use \\*new\\* \\<name\\>\\.\n\n**Two.**" } }
]);
});

test("documentation leads with the deprecation message followed by the title and description", async () => {
fixtureSchemaUri = await client.writeDocument("schema.json", `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"value": {
"properties": {
"name": {
"title": "Name",
"description": "The name of the thing",
"markdownDeprecationMessage": "Use **fullName** instead."
}
}
}
}
}`);
const uri = await client.openDocument("instance.json");

const completions = await client.sendRequest(CompletionRequest.type, {
textDocument: { uri },
position: { line: 3, character: 11 }
}) as CompletionItem[];

expect(completions).toMatchObject([
{
label: "name",
tags: [CompletionItemTag.Deprecated],
documentation: { kind: "markdown", value: "Use **fullName** instead.\n\n**Name**\n\nThe name of the thing" }
}
]);
});

test("a plain text deprecation message is escaped when it's combined with a title or description", async () => {
fixtureSchemaUri = await client.writeDocument("schema.json", `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"value": {
"properties": {
"name": { "title": "Name", "deprecationMessage": "Use *fullName*." }
}
}
}
}`);
const uri = await client.openDocument("instance.json");

const completions = await client.sendRequest(CompletionRequest.type, {
textDocument: { uri },
position: { line: 3, character: 11 }
}) as CompletionItem[];

expect(completions).toMatchObject([
{ label: "name", tags: [CompletionItemTag.Deprecated], documentation: { kind: "markdown", value: "Use \\*fullName\\*\\.\n\n**Name**" } }
]);
});

test("a deprecated property without a message is documented with its title and description", async () => {
fixtureSchemaUri = await client.writeDocument("schema.json", `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"value": {
"properties": {
"name": { "title": "Name", "deprecated": true }
}
}
}
}`);
const uri = await client.openDocument("instance.json");

const completions = await client.sendRequest(CompletionRequest.type, {
textDocument: { uri },
position: { line: 3, character: 11 }
}) as CompletionItem[];

expect(completions).toMatchObject([
{ label: "name", tags: [CompletionItemTag.Deprecated], documentation: { kind: "markdown", value: "**Name**" } }
]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { CompletionItemKind } from "vscode-languageserver";
import * as JsonPointer from "@hyperjump/json-pointer";
import * as Pact from "@hyperjump/pact";
import { LspEvaluationPlugin } from "../../evaluation/LspEvaluationPlugin.ts";
import { tagDeprecated } from "./Completions.ts";
import { describeCompletion } from "./Completions.ts";

import type { CompletionItem } from "vscode-languageserver";
import type { CompletionContext, CompletionsProvider } from "./Completions.ts";
Expand Down Expand Up @@ -59,9 +59,10 @@ export class PropertyCompletionsProvider implements CompletionsProvider {
command: { title: "Suggest", command: "editor.action.triggerSuggest" }
};

// A property is deprecated only if it's deprecated no matter what its value is
// A property is described and deprecated only by the annotations that
// apply no matter what its value is
const propertyLocation = JsonPointer.append(propertyName, instanceLocation);
tagDeprecated(completionItem, plugin.getLocationAnnotations(propertyLocation));
describeCompletion(completionItem, plugin.getLocationAnnotations(propertyLocation));

completionItems.push(completionItem);
}
Expand Down
Loading
Loading