Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added codeAction (extract subSchema to defs) #133

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
2 changes: 2 additions & 0 deletions language-server/src/build-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { ValidationErrorsDiagnosticsProvider } from "./features/diagnostics/vali
import { IfThenCompletionProvider } from "./features/completion/if-then-completion.js";
import { KeywordCompletionProvider } from "./features/completion/keyword-completion.js";
import { SchemaCompletionProvider } from "./features/completion/schema-completion.js";
import { ExtractSubSchemaToDefs } from "./features/codeAction/extractSubschema.js";

// Hyperjump
import { removeMediaTypePlugin } from "@hyperjump/browser";
Expand Down Expand Up @@ -49,6 +50,7 @@ export const buildServer = (connection) => {
new GotoDefinitionFeature(server, schemas);
new FindReferencesFeature(server, schemas);
new HoverFeature(server, schemas);
new ExtractSubSchemaToDefs(server, schemas);

// TODO: It's awkward that diagnostics needs a variable
const diagnostics = new DiagnosticsFeature(server, [
Expand Down
115 changes: 115 additions & 0 deletions language-server/src/features/codeAction/extractSubschema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {
CodeActionKind,
TextDocumentEdit
} from "vscode-languageserver";
import { getKeywordName } from "@hyperjump/json-schema/experimental";
import * as SchemaDocument from "../../model/schema-document.js";
import * as SchemaNode from "../../model/schema-node.js";
import * as jsoncParser from "jsonc-parser";

/**
* @import { Server } from "../../services/server.js"
* @import { Schemas } from "../../services/schemas.js"
* @import { CodeAction } from "vscode-languageserver";
*/


export class ExtractSubSchemaToDefs {
/**
* @param {Server} server
* @param {Schemas} schemas
*/
constructor(server, schemas) {
this.server = server;
this.schemas = schemas;
server.onInitialize(() => ({
capabilities: {
codeActionProvider: true
}
}));

// Helper function to format new def using jsonc-parser
/** @type (newDefText: string) => string */
const formatNewDef = (newDefText) => {
try {
/** @type {unknown} */
const parsedDef = jsoncParser.parse(newDefText);
return JSON.stringify(parsedDef, null, 2).replace(/\n/g, "\n ");
Comment on lines +36 to +37
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't what I meant when I suggested jsonc-parser. You're not using it for anything you couldn't have used JSON.parse for. Look for the format function from jsonc-parser. This solution using JSON.stringify to format isn't going to work for embedded schemas.

{
  "$defs": {
    "this-is-an-embedded-schema-because-it-has-$id": {
      "$id": "my-embedded-schema",
      "$defs": {
        "def1": {
          "$comment": "This definition will need more indentation because it's nested"
        }
      }
    }
  }
}

jsoncParser.format should help get around that problem because you can give it the whole document with the replaced text and tell it to format just the replaced text.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoding the indentation strategy to two spaces isn't going to work. You're going to need to determine what indentation strategy the client is using and match that. You should be able to get the information from the configuration service, but it's currently only configured to retrieve this server's configs. I think there's another "section" you'd have to request, but you'll have to figure out what that is.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have made all the changes, but I am stuck on the formatting part. It always takes the default tab size of four instead of the current tab size. Even though I am fetching the editor settings, it doesn't seem to reflect the actual tab size used in the document. Do you have any suggestions on how to correctly retrieve the active document's indentation settings?
Screenshot 2025-02-26 at 1 38 00 AM

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try sending the document's URI (schemaDocument.textDocument.uri) in the scopeUri parameter in the configuration request. That should return the settings for that file, instead of the settings for the workspace. That's my best guess.

I think it should be ok to add an optional parameter to the get function so you can pass in the document URI.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also tried this approach, but when I console it in the terminal, it still always shows 4. Not sure why it's not picking up the actual tab size. Any other suggestions?

Copy link
Collaborator

@jdesrosiers jdesrosiers Feb 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have any more guesses. I'll try to find some time tonight to try some things and see if I can figure anything out.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's what I figured out.

I think getting 4 is technically correct. Your editor is configured for a 4 space indentation by default. However, vscode also has a setting called "Detect Indentation". If that is set, it ignores the tabSize setting and figures out the indentation based on the content of the file. I haven't found any way to get vscode to tell us the detected indentation. There might not be a way. I think that all we can do is check for the detectIndentation config and if it's true, do our own indentation detection on the server.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, then I'll set detectIndentation: true and handle the indentation detection. Thanks for your help!

} catch {
return newDefText;
}
};

server.onCodeAction(async ({ textDocument, range }) => {
const uri = textDocument.uri;
let schemaDocument = await schemas.getOpen(uri);
if (!schemaDocument) {
return [];
}

const offset = schemaDocument.textDocument.offsetAt(range.start);
const node = SchemaDocument.findNodeAtOffset(schemaDocument, offset);
if (!node?.isSchema) {
return [];
}
let definitionsNode;
for (const schemaNode of SchemaNode.allNodes(node.root)) {
if (schemaNode.keywordUri === "https://json-schema.org/keyword/definitions") {
definitionsNode = schemaNode;
break;
}
}
let highestDefNumber = 0;
if (definitionsNode) {
const defsContent = schemaDocument.textDocument.getText().slice(
definitionsNode.offset,
definitionsNode.offset + definitionsNode.textLength
);
const defMatches = [...defsContent.matchAll(/"def(\d+)":/g)];
defMatches.forEach((match) =>
highestDefNumber = Math.max(highestDefNumber, parseInt(match[1], 10))
);
Comment on lines +64 to +71
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't a good approach. Use keys from schema-node.js to loop over all the property names of the definitionsNode. Then you can use the regex on those values to determine the highestDefNumber.

}
let newDefName = `def${highestDefNumber + 1}`;
const extractedDef = schemaDocument.textDocument.getText(range);
const newFormattedDef = formatNewDef(extractedDef);
let defName = getKeywordName(
/** @type {string} */ (node.root.dialectUri),
"https://json-schema.org/keyword/definitions"
);

/** @type {CodeAction} */
const codeAction = {
title: `Extract subschema to ${defName}`,
kind: CodeActionKind.RefactorExtract,
edit: {
documentChanges: [
TextDocumentEdit.create({ uri: textDocument.uri, version: null }, [
{
range: range,
newText: `{ "$ref": "#/${defName}/${newDefName}" }`
},
definitionsNode
? {
range: {
start: schemaDocument.textDocument.positionAt(definitionsNode.offset + 1),
end: schemaDocument.textDocument.positionAt(definitionsNode.offset + 1)
},
newText: `\n "${newDefName}": ${newFormattedDef},`
}
: {
range: {
start: schemaDocument.textDocument.positionAt(node.root.offset + node.root.textLength - 2),
end: schemaDocument.textDocument.positionAt(node.root.offset + node.root.textLength - 2)
},
newText: `,\n "${defName}": {\n "${newDefName}": ${newFormattedDef}\n }`
}
])
]
}
};

return [codeAction];
});
}
}