diff --git a/README.md b/README.md index abe37c4..e8791f7 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,12 @@ ## Source discovery Iris discovers sources through `spago.lock` by default. To use a command instead, -set `iris.sourceCommand` in your VS Code settings: +set `iris.server.sources` in your VS Code settings: ```json { - "iris.sourceCommand": { + "iris.server.sources": { + "kind": "command", "program": "spago", "arguments": ["sources"] } @@ -18,10 +19,18 @@ set `iris.sourceCommand` in your VS Code settings: The command must print one source path or glob per line. Arguments are passed unchanged, without shell parsing or expansion. Only configure commands you trust. -Omit `arguments` when the program takes no arguments. Set `iris.sourceCommand` to -`null` or remove it to use Spago discovery, provided no deprecated source command -is configured. Reload the VS Code window after changing source discovery settings. +Omit `arguments` when the program takes no arguments. Set `iris.server.sources` to +`{ "kind": "spago" }` to explicitly select Spago over a deprecated source command, +or remove it to inherit startup source discovery, which defaults to Spago. Server +settings apply without reloading the VS Code window. -String values such as `"spago sources"` must be migrated to the object above for -Iris's `--config` interface. The deprecated `purescriptAnalyzer.sourceCommand` -setting uses the same object format. +The deprecated `iris.sourceCommand` setting continues to configure startup source +discovery during migration. + +## Settings + +VS Code client settings use the `iris.client` namespace. For example, set +`iris.client.serverPath` to select a particular Iris executable. Language server +settings use `iris.server`; diagnostic triggers are available as +`iris.server.diagnostics.onOpen`, `iris.server.diagnostics.onSave`, and +`iris.server.diagnostics.onChange`. diff --git a/package.json b/package.json index de013ad..97c8959 100644 --- a/package.json +++ b/package.json @@ -53,50 +53,103 @@ "configuration": { "title": "Iris", "properties": { - "iris.serverPath": { + "iris.client.serverPath": { "type": "string", "default": "", - "description": "Path or command used to start the Iris language server. If unset, Iris searches PATH for iris, then purescript-analyzer." + "description": "Path or command used to start the Iris language server. If unset, Iris searches PATH for iris." }, - "iris.sourceCommand": { + "iris.server.sources": { + "description": "How Iris discovers project sources. Changes apply to the running language server.", + "default": null, + "scope": "resource", + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "required": [ + "kind" + ], + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "const": "spago" + } + } + }, + { + "type": "object", + "required": [ + "kind", + "program" + ], + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "const": "command" + }, + "program": { + "type": "string", + "minLength": 1, + "pattern": "[^\\u0009-\\u000D\\u0020\\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000]", + "description": "Executable name or path, passed unchanged without shell parsing." + }, + "arguments": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "Arguments passed unchanged to the executable." + } + } + } + ] + }, + "iris.server.diagnostics.onOpen": { "type": [ - "object", + "boolean", "null" ], "default": null, - "description": "Executable and arguments used to obtain source files, without shell parsing. If unset, Iris uses spago.lock integration. Reload the window after changing this setting.", - "required": [ - "program" + "scope": "resource", + "description": "Publish diagnostics when a document opens. Iris defaults to true. Changes apply to the running language server." + }, + "iris.server.diagnostics.onSave": { + "type": [ + "boolean", + "null" ], - "additionalProperties": false, - "properties": { - "program": { - "type": "string", - "minLength": 1, - "description": "Executable name or path." - }, - "arguments": { - "type": "array", - "items": { - "type": "string" - }, - "default": [], - "description": "Arguments passed unchanged to the executable." - } - } + "default": null, + "scope": "resource", + "description": "Publish diagnostics when a document is saved. Iris defaults to true. Changes apply to the running language server." }, - "purescriptAnalyzer.serverPath": { + "iris.server.diagnostics.onChange": { + "type": [ + "boolean", + "null" + ], + "default": null, + "scope": "resource", + "description": "Publish diagnostics when a document changes. Iris defaults to false. Changes apply to the running language server." + }, + "iris.serverPath": { "type": "string", "default": "", - "description": "Deprecated. Use iris.serverPath instead.", - "deprecationMessage": "Use iris.serverPath instead." + "description": "Deprecated. Use iris.client.serverPath instead.", + "deprecationMessage": "Use iris.client.serverPath instead." }, - "purescriptAnalyzer.sourceCommand": { + "iris.sourceCommand": { "type": [ "object", "null" ], "default": null, + "description": "Deprecated. Use iris.server.sources instead.", + "deprecationMessage": "Use iris.server.sources instead.", "required": [ "program" ], @@ -104,18 +157,18 @@ "properties": { "program": { "type": "string", - "minLength": 1 + "minLength": 1, + "description": "Executable name or path." }, "arguments": { "type": "array", "items": { "type": "string" }, - "default": [] + "default": [], + "description": "Arguments passed unchanged to the executable." } - }, - "description": "Deprecated. Use iris.sourceCommand instead.", - "deprecationMessage": "Use iris.sourceCommand instead." + } } } } diff --git a/src/configuration.ts b/src/configuration.ts index c919629..432c64d 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -1,21 +1,24 @@ import * as fs from "fs"; import * as path from "path"; -export const defaultServerCommands = ["iris", "purescript-analyzer"]; +export const defaultServerCommand = "iris"; export interface SourceCommand { program: string; arguments?: string[]; } -export interface ExtensionSettings { +export interface ClientSettings { serverPath?: string; +} + +export interface LegacySettings extends ClientSettings { sourceCommand?: SourceCommand | null; } export interface ConfigurationInput { - iris?: ExtensionSettings; - purescriptAnalyzer?: ExtensionSettings; + client?: ClientSettings; + iris?: LegacySettings; pathValue?: string; platform?: NodeJS.Platform; pathExtensions?: string; @@ -42,10 +45,10 @@ export function resolveConfiguration( export function resolveServerPath(input: ConfigurationInput) { return ( + trimmed(input.client?.serverPath) || trimmed(input.iris?.serverPath) || - trimmed(input.purescriptAnalyzer?.serverPath) || - findFirstExecutable( - defaultServerCommands, + findExecutable( + defaultServerCommand, input.pathValue ?? process.env.PATH ?? "", { fileSystem: input.fileSystem, @@ -53,16 +56,12 @@ export function resolveServerPath(input: ConfigurationInput) { platform: input.platform, }, ) || - defaultServerCommands[0] + defaultServerCommand ); } export function resolveSourceCommand(input: ConfigurationInput) { - return ( - input.iris?.sourceCommand ?? - input.purescriptAnalyzer?.sourceCommand ?? - undefined - ); + return input.iris?.sourceCommand ?? undefined; } export interface FindExecutableOptions { @@ -71,20 +70,6 @@ export interface FindExecutableOptions { fileSystem?: ExecutableFileSystem; } -export function findFirstExecutable( - commands: readonly string[], - pathValue: string, - options: FindExecutableOptions = {}, -) { - for (const command of commands) { - const executablePath = findExecutable(command, pathValue, options); - if (executablePath) { - return executablePath; - } - } - return undefined; -} - export function findExecutable( command: string, pathValue: string, diff --git a/src/extension.ts b/src/extension.ts index 91f0dda..ad99329 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -12,20 +12,19 @@ import { let client: LanguageClient; export function activate(context: ExtensionContext) { - const config = workspace.getConfiguration("iris"); - const legacyConfig = workspace.getConfiguration("purescriptAnalyzer"); + const clientConfig = workspace.getConfiguration("iris.client"); + const irisConfig = workspace.getConfiguration("iris"); const resolvedConfig = resolveConfiguration({ - iris: { - serverPath: config.get("serverPath"), - sourceCommand: config.get("sourceCommand"), + client: { + serverPath: clientConfig.get("serverPath"), }, - purescriptAnalyzer: { - serverPath: legacyConfig.get("serverPath"), - sourceCommand: legacyConfig.get("sourceCommand"), + iris: { + serverPath: irisConfig.get("serverPath"), + sourceCommand: irisConfig.get("sourceCommand"), }, }); - const args: string[] = []; + const args = ["lsp"]; if (resolvedConfig.sourceCommand) { args.push( "--config", diff --git a/test/integration/runTest.ts b/test/integration/runTest.ts index 0543742..18272e0 100644 --- a/test/integration/runTest.ts +++ b/test/integration/runTest.ts @@ -70,9 +70,6 @@ async function main() { "--skip-release-notes", "--skip-welcome", ], - extensionTestsEnv: { - IRIS_PATH: irisPath, - }, }); } @@ -163,8 +160,9 @@ function prepareWorkspace( path.join(vscodeDirectory, "settings.json"), JSON.stringify( { - "iris.serverPath": irisPath, - "iris.sourceCommand": { + "iris.client.serverPath": irisPath, + "iris.server.sources": { + kind: "command", program: process.execPath, arguments: [sourceFilesScript], }, diff --git a/test/integration/suite/features/activation.test.ts b/test/integration/suite/features/activation.test.ts deleted file mode 100644 index 6b3da39..0000000 --- a/test/integration/suite/features/activation.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import * as assert from "assert"; - -import { suite, test } from "mocha"; - -import { - integrationTestContext, - openWorkspaceDocument, -} from "../support/context"; - -suite("Activation", () => { - test("activates for a registered PureScript document", async () => { - const context = await integrationTestContext(); - const document = await openWorkspaceDocument(context, "Main.purs"); - - assert.strictEqual(context.extension.isActive, true); - assert.strictEqual(document.languageId, "purescript"); - }); -}); diff --git a/test/unit/configuration.test.ts b/test/unit/configuration.test.ts index 5af819c..7a2edbe 100644 --- a/test/unit/configuration.test.ts +++ b/test/unit/configuration.test.ts @@ -2,12 +2,7 @@ import * as assert from "assert"; import * as path from "path"; import { describe, test } from "vitest"; -import { - defaultServerCommands, - findExecutable, - findFirstExecutable, - resolveConfiguration, -} from "../../src/configuration"; +import { findExecutable, resolveConfiguration } from "../../src/configuration"; class FakeFileSystem { constructor(private readonly executableFiles: readonly string[]) {} @@ -18,19 +13,18 @@ class FakeFileSystem { } describe("configuration", () => { - test("prefers Iris settings over legacy settings", () => { + test("prefers client settings over legacy settings", () => { const config = resolveConfiguration({ - iris: { + client: { serverPath: " /bin/iris ", + }, + iris: { + serverPath: "/bin/flat-iris", sourceCommand: { program: "C:\\Program Files\\node.exe", arguments: ["source files.js", ' quoted "value" ', ""], }, }, - purescriptAnalyzer: { - serverPath: "/bin/purescript-analyzer", - sourceCommand: { program: "legacy-source-command" }, - }, pathValue: "", }); @@ -41,67 +35,56 @@ describe("configuration", () => { }); }); - test("uses legacy settings when Iris settings are empty", () => { + test("uses flat Iris settings when client settings are empty", () => { const config = resolveConfiguration({ - iris: { + client: { serverPath: " ", - sourceCommand: null, }, - purescriptAnalyzer: { - serverPath: " /bin/purescript-analyzer ", - sourceCommand: { program: "legacy-source-command" }, + iris: { + serverPath: " /bin/iris ", + sourceCommand: { program: "iris-source-command" }, }, pathValue: "", }); - assert.strictEqual(config.serverPath, "/bin/purescript-analyzer"); + assert.strictEqual(config.serverPath, "/bin/iris"); assert.deepStrictEqual(config.sourceCommand, { - program: "legacy-source-command", + program: "iris-source-command", }); }); - test("uses Spago source discovery when both source commands are unset", () => { + test("leaves source discovery unspecified when the source command is unset", () => { const config = resolveConfiguration({ iris: { sourceCommand: null }, - purescriptAnalyzer: { sourceCommand: null }, pathValue: "", }); assert.strictEqual(config.sourceCommand, undefined); }); - test("searches server commands in the expected order", () => { - assert.deepStrictEqual(defaultServerCommands, [ - "iris", - "purescript-analyzer", - ]); - }); - - test("finds the first executable server command on PATH", () => { + test("resolves iris from PATH", () => { const firstDirectory = path.join("tmp", "first"); const secondDirectory = path.join("tmp", "second"); const pathValue = [firstDirectory, secondDirectory].join(":"); - const fileSystem = new FakeFileSystem([ - path.join(firstDirectory, "purescript-analyzer"), - path.join(secondDirectory, "iris"), - ]); + const fileSystem = new FakeFileSystem([path.join(secondDirectory, "iris")]); - const executablePath = findFirstExecutable( - defaultServerCommands, + const config = resolveConfiguration({ + fileSystem, pathValue, - { - fileSystem, - platform: "darwin", - }, - ); + platform: "darwin", + }); - assert.strictEqual(executablePath, path.join(secondDirectory, "iris")); + assert.strictEqual(config.serverPath, path.join(secondDirectory, "iris")); }); - test("falls back to iris when no server command is found", () => { + test("does not detect the legacy server command", () => { + const directory = path.join("tmp", "bin"); const config = resolveConfiguration({ - pathValue: "", - fileSystem: new FakeFileSystem([]), + pathValue: directory, + fileSystem: new FakeFileSystem([ + path.join(directory, "purescript-analyzer"), + ]), + platform: "darwin", }); assert.strictEqual(config.serverPath, "iris"); @@ -110,10 +93,13 @@ describe("configuration", () => { test("uses PATHEXT when searching for Windows executables", () => { const directory = "C:\\Tools"; - const executablePath = path.join(directory, "iris.EXE"); + const executablePath = path.join(directory, "iris.CMD"); const result = findExecutable("iris", directory, { - fileSystem: new FakeFileSystem([executablePath]), - pathExtensions: ".EXE;.CMD", + fileSystem: new FakeFileSystem([ + path.join(directory, "iris.EXE"), + executablePath, + ]), + pathExtensions: ".CMD;.EXE", platform: "win32", }); diff --git a/test/unit/manifest.test.ts b/test/unit/manifest.test.ts index 81d2b84..d90dada 100644 --- a/test/unit/manifest.test.ts +++ b/test/unit/manifest.test.ts @@ -11,49 +11,77 @@ describe("manifest", () => { assert.strictEqual(packageJson.publisher, "purefunctor"); }); - test("contributes preferred and legacy settings", () => { + test("keeps server path defaults empty for runtime fallback", () => { const properties = packageJson.contributes.configuration.properties; - assert.ok(properties["iris.serverPath"]); - assert.ok(properties["iris.sourceCommand"]); - assert.ok(properties["purescriptAnalyzer.serverPath"]); - assert.ok(properties["purescriptAnalyzer.sourceCommand"]); + assert.strictEqual(properties["iris.client.serverPath"].default, ""); + assert.strictEqual(properties["iris.serverPath"].default, ""); }); - test("keeps server path defaults empty for runtime fallback", () => { + test("describes the server source-discovery schema without overriding defaults", () => { const properties = packageJson.contributes.configuration.properties; + const sources = properties["iris.server.sources"]; - assert.strictEqual(properties["iris.serverPath"].default, ""); - assert.strictEqual(properties["purescriptAnalyzer.serverPath"].default, ""); + assert.strictEqual(sources.default, null); + assert.strictEqual(sources.scope, "resource"); + assert.strictEqual( + sources.oneOf.some((alternative) => alternative.type === "null"), + true, + ); + const spago = sources.oneOf.find( + (alternative) => alternative.properties?.kind?.const === "spago", + ); + assert.ok(spago); + assert.deepStrictEqual(spago.required, ["kind"]); + assert.strictEqual(spago.additionalProperties, false); + const command = sources.oneOf.find( + (alternative) => alternative.properties?.kind?.const === "command", + ); + assert.ok(command); + assert.deepStrictEqual(command.required?.slice().sort(), [ + "kind", + "program", + ]); + assert.strictEqual(command.additionalProperties, false); + assert.strictEqual(command.properties.program?.type, "string"); + assert.strictEqual(command.properties.arguments?.type, "array"); + assert.strictEqual(command.properties.arguments?.items.type, "string"); }); - test("describes structured source commands without overriding Spago defaults", () => { + test("does not override server diagnostic defaults", () => { const properties = packageJson.contributes.configuration.properties; - for (const setting of [ - properties["iris.sourceCommand"], - properties["purescriptAnalyzer.sourceCommand"], - ]) { - assert.deepStrictEqual(setting.type, ["object", "null"]); + for (const name of ["onOpen", "onSave", "onChange"] as const) { + const setting = properties[`iris.server.diagnostics.${name}`]; + assert.deepStrictEqual(setting.type, ["boolean", "null"]); assert.strictEqual(setting.default, null); - assert.deepStrictEqual(setting.required, ["program"]); - assert.strictEqual(setting.additionalProperties, false); - assert.strictEqual(setting.properties.program.type, "string"); - assert.strictEqual(setting.properties.arguments.type, "array"); - assert.strictEqual(setting.properties.arguments.items.type, "string"); + assert.strictEqual(setting.scope, "resource"); } }); + test("keeps the legacy source command structured", () => { + const properties = packageJson.contributes.configuration.properties; + const setting = properties["iris.sourceCommand"]; + + assert.deepStrictEqual(setting.type, ["object", "null"]); + assert.strictEqual(setting.default, null); + assert.deepStrictEqual(setting.required, ["program"]); + assert.strictEqual(setting.additionalProperties, false); + assert.strictEqual(setting.properties.program.type, "string"); + assert.strictEqual(setting.properties.arguments.type, "array"); + assert.strictEqual(setting.properties.arguments.items.type, "string"); + }); + test("marks legacy settings as deprecated", () => { const properties = packageJson.contributes.configuration.properties; assert.match( - properties["purescriptAnalyzer.serverPath"].deprecationMessage, - /iris\.serverPath/, + properties["iris.serverPath"].deprecationMessage, + /iris\.client\.serverPath/, ); assert.match( - properties["purescriptAnalyzer.sourceCommand"].deprecationMessage, - /iris\.sourceCommand/, + properties["iris.sourceCommand"].deprecationMessage, + /iris\.server\.sources/, ); }); });