Skip to content

Commit

Permalink
Refactor to give things more structure
Browse files Browse the repository at this point in the history
  • Loading branch information
jdesrosiers committed May 14, 2024
1 parent 6a12c9d commit 4fd9a2a
Show file tree
Hide file tree
Showing 13 changed files with 1,236 additions and 602 deletions.
39 changes: 39 additions & 0 deletions language-server/src/features/completion.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { getDialectIds } from "@hyperjump/json-schema/experimental";
import { getSchemaDocument } from "./schema-documents.js";
import { CompletionItemKind } from "vscode-languageserver";


export default {
onInitialize() {
return {
completionProvider: {
resolveProvider: false,
triggerCharacters: ["\"", ":", " "]
}
};
},

onInitialized(connection, documents) {
connection.onCompletion(async ({ textDocument, position }) => {
const document = documents.get(textDocument.uri);
const schemaDocument = await getSchemaDocument(connection, document);
const offset = document.offsetAt(position);
const currentProperty = schemaDocument.findNodeAtOffset(offset);
if (currentProperty.pointer.endsWith("/$schema")) {
return getDialectIds().map((uri) => {
return {
label: shouldHaveTrailingHash(uri) ? `${uri}#` : uri,
kind: CompletionItemKind.Value
};
});
}
});

const trailingHashDialects = new Set([
"http://json-schema.org/draft-04/schema",
"http://json-schema.org/draft-06/schema",
"http://json-schema.org/draft-07/schema"
]);
const shouldHaveTrailingHash = (uri) => trailingHashDialects.has(uri);
}
};
25 changes: 25 additions & 0 deletions language-server/src/features/deprecated.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { DiagnosticSeverity, DiagnosticTag } from "vscode-languageserver";
import * as Instance from "../json-instance.js";
import { subscribe } from "../pubsub.js";


export default {
onInitialize() {
return {};
},

onInitialized() {
subscribe("diagnostics", async (_message, { schemaDocument, diagnostics }) => {
for (const deprecated of schemaDocument.annotatedWith("deprecated")) {
if (Instance.annotation(deprecated, "deprecated").some((deprecated) => deprecated)) {
diagnostics.push({
instance: deprecated.parent,
message: Instance.annotation(deprecated, "x-deprecationMessage").join("\n") || "deprecated",
severity: DiagnosticSeverity.Warning,
tags: [DiagnosticTag.Deprecated]
});
}
}
});
}
};
56 changes: 56 additions & 0 deletions language-server/src/features/document-settings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { DidChangeConfigurationNotification } from "vscode-languageserver";
import { publish } from "../pubsub.js";


export const isSchema = RegExp.prototype.test.bind(/(?:\.|\/|^)schema\.json$/);

let hasConfigurationCapability = false;
let hasDidChangeConfigurationCapability = false;

export default {
onInitialize: ({ capabilities }) => {
hasConfigurationCapability = !!capabilities.workspace?.configuration;
hasDidChangeConfigurationCapability = !!capabilities.workspace?.didChangeConfiguration?.dynamicRegistration;

return {};
},

onInitialized: (connection, documents) => {
if (hasDidChangeConfigurationCapability) {
connection.client.register(DidChangeConfigurationNotification.type);
}

connection.onDidChangeConfiguration((change) => {
if (hasConfigurationCapability) {
documentSettings.clear();
} else {
globalSettings = change.settings.jsonSchemaLanguageServer ?? globalSettings;
}

publish("workspaceChange", { changes: [] });
});

documents.onDidClose(({ document }) => {
documentSettings.delete(document.uri);
});
}
};

const documentSettings = new Map();
let globalSettings = {};

export const getDocumentSettings = async (connection, uri) => {
if (!hasConfigurationCapability) {
return globalSettings;
}

if (!documentSettings.has(uri)) {
const result = await connection.workspace.getConfiguration({
scopeUri: uri,
section: "jsonSchemaLanguageServer"
});
documentSettings.set(uri, result ?? globalSettings);
}

return documentSettings.get(uri);
};
36 changes: 36 additions & 0 deletions language-server/src/features/hover.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { MarkupKind } from "vscode-languageserver";
import * as Instance from "../json-instance.js";
import { getSchemaDocument } from "./schema-documents.js";


export default {
onInitialize() {
return {
hoverProvider: true
};
},

onInitialized(connection, documents) {
connection.onHover(async ({ textDocument, position }) => {
const document = documents.get(textDocument.uri);
const schemaDocument = await getSchemaDocument(connection, document);
const offset = document.offsetAt(position);
const keyword = schemaDocument.findNodeAtOffset(offset);

// This is a little wierd because we want the hover to be on the keyword, but
// the annotation is actually on the value not the keyword.
if (keyword.parent && Instance.typeOf(keyword.parent) === "property" && keyword.parent.children[0] === keyword) {
return {
contents: {
kind: MarkupKind.Markdown,
value: Instance.annotation(keyword.parent.children[1], "description").join("\n")
},
range: {
start: document.positionAt(keyword.offset),
end: document.positionAt(keyword.offset + keyword.textLength)
}
};
}
});
}
};
32 changes: 32 additions & 0 deletions language-server/src/features/schema-documents.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { getDocumentSettings } from "./document-settings.js";
import { JsonSchemaDocument } from "../json-schema-document.js";


export default {
onInitialize() {
return {};
},

onInitialized(_connection, documents) {
documents.onDidClose(({ document }) => {
schemaDocuments.delete(document.uri);
});
}
};

const schemaDocuments = new Map();

export const getSchemaDocument = async (connection, textDocument) => {
let { version, schemaDocument } = schemaDocuments.get(textDocument.uri) ?? {};

if (version !== textDocument.version) {
const settings = await getDocumentSettings(connection, textDocument.uri);
schemaDocument = await JsonSchemaDocument.fromTextDocument(textDocument, settings.defaultDialect);

if (textDocument.version !== -1) {
schemaDocuments.set(textDocument.uri, { version: textDocument.version, schemaDocument });
}
}

return schemaDocument;
};
190 changes: 190 additions & 0 deletions language-server/src/features/semantic-tokens.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { SemanticTokensBuilder } from "vscode-languageserver";
import { getKeywordId } from "@hyperjump/json-schema/experimental";
import * as Instance from "../json-instance.js";
import { getSchemaDocument } from "./schema-documents.js";
import { toAbsoluteUri } from "../util.js";
import { isSchema } from "./document-settings.js";


export default {
onInitialize({ capabilities }) {
return {
semanticTokensProvider: {
legend: buildSemanticTokensLegend(capabilities.textDocument?.semanticTokens),
range: false,
full: {
delta: true
}
}
};
},

onInitialized(connection, documents) {
const tokenBuilders = new Map();

documents.onDidClose(({ document }) => {
tokenBuilders.delete(document.uri);
});

const getTokenBuilder = (uri) => {
if (!tokenBuilders.has(uri)) {
tokenBuilders.set(uri, new SemanticTokensBuilder());
}

return tokenBuilders.get(uri);
};

const buildTokens = async (builder, uri) => {
const textDocument = documents.get(uri);
const schemaDocument = await getSchemaDocument(connection, textDocument);
const semanticTokens = getSemanticTokens(schemaDocument);
for (const { keywordInstance, tokenType, tokenModifier } of sortSemanticTokens(semanticTokens, textDocument)) {
const startPosition = textDocument.positionAt(keywordInstance.offset);
builder.push(
startPosition.line,
startPosition.character,
keywordInstance.textLength,
semanticTokensLegend.tokenTypes[tokenType] ?? 0,
semanticTokensLegend.tokenModifiers[tokenModifier] ?? 0
);
}
};

connection.languages.semanticTokens.on(async ({ textDocument }) => {
if (!isSchema(textDocument.uri)) {
return { data: [] };
}

const builder = getTokenBuilder(textDocument.uri);
await buildTokens(builder, textDocument.uri);

return builder.build();
});

connection.languages.semanticTokens.onDelta(async ({ textDocument, previousResultId }) => {
const builder = getTokenBuilder(textDocument.uri);
builder.previousResult(previousResultId);
await buildTokens(builder, textDocument.uri);

return builder.buildEdits();
});
}
};

const semanticTokensLegend = {
tokenTypes: {},
tokenModifiers: {}
};

const buildSemanticTokensLegend = (capability) => {
const clientTokenTypes = new Set(capability.tokenTypes);
const serverTokenTypes = [
"property",
"keyword",
"comment",
"string",
"regexp"
];

const tokenTypes = [];
for (const tokenType of serverTokenTypes) {
if (clientTokenTypes.has(tokenType)) {
semanticTokensLegend.tokenTypes[tokenType] = tokenTypes.length;
tokenTypes.push(tokenType);
}
}

const clientTokenModifiers = new Set(capability.tokenModifiers);
const serverTokenModifiers = [
];

const tokenModifiers = [];
for (const tokenModifier of serverTokenModifiers) {
if (clientTokenModifiers.has(tokenModifier)) {
semanticTokensLegend.tokenModifiers[tokenModifier] = tokenModifiers.length;
tokenModifiers.push(tokenModifier);
}
}

return { tokenTypes, tokenModifiers };
};

// VSCode requires this list to be in order. Neovim doesn't care.
const sortSemanticTokens = (semanticTokens, textDocument) => {
return [...semanticTokens].sort((a, b) => {
const aStartPosition = textDocument.positionAt(a.keywordInstance.offset);
const bStartPosition = textDocument.positionAt(b.keywordInstance.offset);

return aStartPosition.line === bStartPosition.line
? aStartPosition.character - bStartPosition.character
: aStartPosition.line - bStartPosition.line;
});
};

const getSemanticTokens = function* (schemaDocument) {
for (const { schemaResource, dialectUri } of schemaDocument.schemaResources) {
yield* schemaHandler(schemaResource, dialectUri);
}
};

const schemaHandler = function* (schemaResource, dialectUri) {
for (const [keyNode, valueNode] of Instance.entries(schemaResource)) {
const keywordName = Instance.value(keyNode);
const keywordId = keywordIdFor(keywordName, dialectUri);

if (keywordId) {
if (keywordId === "https://json-schema.org/keyword/comment") {
yield { keywordInstance: keyNode.parent, tokenType: "comment" };
} else if (toAbsoluteUri(keywordId) !== "https://json-schema.org/keyword/unknown") {
yield { keywordInstance: keyNode, tokenType: "keyword" };
yield* getKeywordHandler(keywordId)(valueNode, dialectUri);
}
}
}
};

const keywordIdFor = (keywordName, dialectUri) => {
try {
return keywordName === "$schema"
? "https://json-schema.org/keyword/schema"
: getKeywordId(keywordName, dialectUri);
} catch (error) {
return;
}
};

const schemaMapHandler = function* (schemaResource, dialectUri) {
for (const schemaNode of Instance.values(schemaResource)) {
yield* schemaHandler(schemaNode, dialectUri);
}
};

const schemaArrayHandler = function* (schemaResource, dialectUri) {
for (const schemaNode of Instance.iter(schemaResource)) {
yield* schemaHandler(schemaNode, dialectUri);
}
};

const noopKeywordHandler = function* () {};
const getKeywordHandler = (keywordId) => keywordId in keywordHandlers ? keywordHandlers[keywordId] : noopKeywordHandler;

const keywordHandlers = {
"https://json-schema.org/keyword/additionalProperties": schemaHandler,
"https://json-schema.org/keyword/allOf": schemaArrayHandler,
"https://json-schema.org/keyword/anyOf": schemaArrayHandler,
"https://json-schema.org/keyword/contains": schemaHandler,
"https://json-schema.org/keyword/definitions": schemaMapHandler,
"https://json-schema.org/keyword/dependentSchemas": schemaMapHandler,
"https://json-schema.org/keyword/if": schemaHandler,
"https://json-schema.org/keyword/then": schemaHandler,
"https://json-schema.org/keyword/else": schemaHandler,
"https://json-schema.org/keyword/items": schemaArrayHandler,
"https://json-schema.org/keyword/not": schemaHandler,
"https://json-schema.org/keyword/oneOf": schemaArrayHandler,
"https://json-schema.org/keyword/patternProperties": schemaMapHandler,
"https://json-schema.org/keyword/prefixItems": schemaArrayHandler,
"https://json-schema.org/keyword/properties": schemaMapHandler,
"https://json-schema.org/keyword/propertyNames": schemaHandler,
"https://json-schema.org/keyword/unevaluatedItems": schemaHandler,
"https://json-schema.org/keyword/unevaluatedProperties": schemaHandler
};
Loading

0 comments on commit 4fd9a2a

Please sign in to comment.