Skip to content
Merged
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ notes, but they do not trigger or gate the submodule updates.

### Optional configuration

For an opt-in typed settings contract without changing runtime behavior, see
the [typed tool definition example](typescript/README.md#typed-tool-definitions).
Existing `ToolConfig` callers do not need to migrate.

Extensions whose servers run tools using the interpreter in each resolved
settings entry can opt in to per-project environments:

Expand Down
79 changes: 79 additions & 0 deletions typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,82 @@ published package or a supported bare-module import.

See the [main README](../README.md#consuming-in-extensions) for build order,
consumer type-checking and bundling instructions.

## Typed tool definitions

Adoption is optional. Annotate a definition with `ToolDefinition<TSettings>` to
check custom defaults and retain the declared types through all three settings
resolvers. The example belongs in an extension's `src/extension.ts`, after
building the checkout under `external/` as described above. Ordinary interfaces
work without an index signature:

```typescript
import { WorkspaceFolder } from 'vscode';
import {
getExtensionSettings,
getGlobalSettings,
getWorkspaceSettings,
IInitOptions,
ToolConfig,
ToolDefinition,
} from '../external/vscode-common-python-lsp/typescript/dist';

interface LintSettings {
ignorePatterns: string[];
severity: Record<string, string>;
enabled: boolean;
optionalLabel?: string;
}

const tool: ToolDefinition<LintSettings> = {
toolId: 'example',
toolDisplayName: 'Example',
toolModule: 'example',
minimumPythonVersion: { major: 3, minor: 10 },
configFiles: ['tool.toml'],
serverScript: 'bundled/tool/lsp_server.py', // Use the extension's server path.
settingsDefaults: {
ignorePatterns: [],
severity: { E: 'Error' },
enabled: false,
},
trackedSettings: ['args', 'path', 'interpreter', 'ignorePatterns', 'severity', 'envFile'],
};

// Also accepted directly as createToolContext({ ..., toolConfig: tool }).
export const legacyCompatible: ToolConfig = tool;

export async function resolveExampleSettings(workspace: WorkspaceFolder) {
const resolved = await getWorkspaceSettings('example', workspace, tool);
const patterns: string[] = resolved.ignorePatterns;
const enabled: boolean = resolved.enabled;
const optionalLabel: string | undefined = resolved.optionalLabel;
const futureOption: unknown = resolved.futureOption; // Narrow before use.

const initializationOptions: IInitOptions = {
settings: await getExtensionSettings('example', tool, undefined, [workspace]),
globalSettings: await getGlobalSettings('example', tool),
};
return { patterns, enabled, optionalLabel, futureOption, initializationOptions };
}
```

Use the explicit annotation to retain the full declared types: `false` and `[]`
are defaults, not literal restrictions on resolved values. Required custom
fields require defaults. Optional fields remain optional; omitting a default
also omits that field from resolution, even if it is configured. Common fields
keep their existing `IBaseSettings` types and cannot be contradicted by the
custom declaration. `ToolSettings<TSettings>` names the resulting open settings
type; future wire fields stay `unknown`.

The resolver signatures infer custom fields from the supplied configuration's
`settingsDefaults`. An unparameterized `ToolConfig` retains the legacy
`IBaseSettings` surface. Definitions also pass directly to existing lifecycle
APIs; no generic lifecycle classes or migration are required.

This is a compile-time contract, not runtime validation or a schema.
Configuration values, shallow replacement, variable resolution, manifest
defaults and the `{ settings, globalSettings }` initialization payload are
unchanged. Only custom keys in `settingsDefaults` are projected. Keep
`trackedSettings` explicit: a client-only key such as `envFile` may be watched
without being serialized.
10 changes: 9 additions & 1 deletion typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,15 @@
*/

// Types
export { IBaseSettings, IInitOptions, IResolvedPythonEnvironment, IServerInfo, ToolConfig } from './types';
export {
IBaseSettings,
IInitOptions,
IResolvedPythonEnvironment,
IServerInfo,
ToolConfig,
ToolDefinition,
ToolSettings,
} from './types';

// Logging
export { registerLogger, traceError, traceInfo, traceLog, traceVerbose, traceWarn } from './logging';
Expand Down
19 changes: 18 additions & 1 deletion typescript/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import * as os from 'os';
import * as path from 'path';
import { ConfigurationChangeEvent, Uri, WorkspaceConfiguration, WorkspaceFolder } from 'vscode';
import { traceLog, traceWarn } from './logging';
import { IBaseSettings, ToolConfig } from './types';
import { IBaseSettings, ToolConfig, ToolSettings } from './types';
import { getConfiguration, getWorkspaceFolder, getWorkspaceFolders } from './vscodeapi';

// Re-export for convenience — callers that used these from the old
Expand Down Expand Up @@ -165,6 +165,12 @@ function getInterpreterSettingValue(namespace: string, workspace?: WorkspaceFold
* When `workspace` is a Python project nested inside a VS Code workspace,
* variable substitution still uses the containing VS Code workspace folder.
*/
export function getWorkspaceSettings<TConfig extends ToolConfig>(
namespace: string,
workspace: WorkspaceFolder,
toolConfig: TConfig,
resolveInterpreter?: (resource?: Uri) => Promise<{ path?: string[] }>,
): Promise<ToolSettings<TConfig['settingsDefaults']>>;
export async function getWorkspaceSettings(
namespace: string,
workspace: WorkspaceFolder,
Expand Down Expand Up @@ -233,6 +239,11 @@ export async function getWorkspaceSettings(
/**
* Resolve global (user-level) settings.
*/
export function getGlobalSettings<TConfig extends ToolConfig>(
namespace: string,
toolConfig: TConfig,
resolveInterpreter?: () => Promise<{ path?: string[] }>,
): Promise<ToolSettings<TConfig['settingsDefaults']>>;
export async function getGlobalSettings(
namespace: string,
toolConfig: ToolConfig,
Expand Down Expand Up @@ -278,6 +289,12 @@ export async function getGlobalSettings(
* default. Consumers may supply Python project roots to enable per-project
* interpreter resolution while retaining a singleton language server.
*/
export function getExtensionSettings<TConfig extends ToolConfig>(
namespace: string,
toolConfig: TConfig,
resolveInterpreter?: (resource?: Uri) => Promise<{ path?: string[] }>,
workspaces?: readonly WorkspaceFolder[],
): Promise<ToolSettings<TConfig['settingsDefaults']>[]>;
export function getExtensionSettings(
namespace: string,
toolConfig: ToolConfig,
Expand Down
34 changes: 32 additions & 2 deletions typescript/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ export interface IServerInfo {
module: string;
}

/** Base settings resolved per workspace by the settings module. */
export interface IBaseSettings {
interface ICommonSettings {
cwd: string;
workspace: string;
args: string[];
Expand All @@ -21,6 +20,10 @@ export interface IBaseSettings {
importStrategy: string;
showNotifications: string;
extraPaths?: string[];
}

/** Base settings resolved per workspace by the settings module. */
export interface IBaseSettings extends ICommonSettings {
[key: string]: unknown;
}

Expand Down Expand Up @@ -111,6 +114,33 @@ export interface ToolConfig {
isFormatter?: boolean;
}

type CompatibleSettings<TSettings> = {
[K in keyof TSettings]: K extends keyof ICommonSettings ? ICommonSettings[K] : unknown;
};

/**
* Opt-in tool configuration with declared custom settings.
*
* Every required custom field needs a default because only keys in
* `settingsDefaults` are projected by the resolvers. Optional fields may
* be omitted and remain optional in resolved settings.
*/
export type ToolDefinition<TSettings extends object & CompatibleSettings<TSettings>> = Omit<
ToolConfig,
'settingsDefaults'
> & {
// A mapped shape accepts ordinary interfaces while remaining compatible
// with ToolConfig's open settingsDefaults record.
settingsDefaults: { [K in keyof TSettings]: TSettings[K] };
};

/**
* Resolved custom settings with the common fields and an open, unknown-valued
* wire boundary. Common fields retain their existing types, even when a
* definition supplies narrower defaults.
*/
export type ToolSettings<TSettings extends object> = IBaseSettings & Omit<TSettings, keyof ICommonSettings>;

/**
* Canonical resolved Python environment — API-agnostic.
*
Expand Down
64 changes: 63 additions & 1 deletion typescript/tests/settingsWorkspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as path from 'path';
import * as sinon from 'sinon';
import { pathToFileURL } from 'url';
import { Uri, WorkspaceFolder } from 'vscode';
import { ToolDefinition } from '../src';
import {
checkIfConfigurationChanged,
expandTilde,
Expand Down Expand Up @@ -82,6 +83,10 @@ suite('settings — workspace & global resolution', () => {
'settings',
'producer.json',
);
interface FixtureSettings {
severity: Record<string, string>;
extensionOption: { enabled: boolean; label?: string };
}
const fixture = JSON.parse(readFileSync(fixturePath, 'utf8'), (_key: string, value: unknown) => {
if (typeof value !== 'string') {
return value;
Expand All @@ -92,7 +97,7 @@ suite('settings — workspace & global resolution', () => {
}
return expanded;
}) as {
toolDefaults: Record<string, unknown>;
toolDefaults: ToolDefinition<FixtureSettings>['settingsDefaults'];
configuration: { workspace: Record<string, unknown>; global: Record<string, unknown> };
initializationOptions: IInitOptions;
};
Expand All @@ -109,6 +114,63 @@ suite('settings — workspace & global resolution', () => {
};

assert.deepEqual(actual, fixture.initializationOptions);

const typedToolConfig: ToolDefinition<FixtureSettings> = {
...toolConfig,
settingsDefaults: fixture.toolDefaults,
};
const typed: IInitOptions = {
settings: await getExtensionSettings('flake8', typedToolConfig, undefined, [root]),
globalSettings: await getGlobalSettings('flake8', typedToolConfig),
};
assert.deepEqual(typed, actual);
assert.deepEqual(await getWorkspaceSettings('flake8', root, typedToolConfig), actual.settings[0]);
});

test('typed definitions preserve defaults and project only declared custom keys', async () => {
interface OptionalSettings {
enabled: boolean;
ignorePatterns: string[];
optionalLabel?: string;
}
const typed: ToolDefinition<OptionalSettings> = {
...makeToolConfig(),
settingsDefaults: { enabled: false, ignorePatterns: [] },
trackedSettings: ['enabled', 'ignorePatterns', 'envFile'],
};
const configured: Record<string, unknown> = {
optionalLabel: 'not projected without a default',
envFile: 'client-only.env',
futureOption: true,
};
getConfigurationStub.returns({
get: (key: string, fallback?: unknown) => configured[key] ?? fallback,
inspect: (key: string) => ({ globalValue: configured[key] }),
});
const legacy: ToolConfig = typed;
const typedInitialization: IInitOptions = {
settings: await getExtensionSettings('flake8', typed),
globalSettings: await getGlobalSettings('flake8', typed),
};
assert.deepEqual(typedInitialization, {
settings: await getExtensionSettings('flake8', legacy),
globalSettings: await getGlobalSettings('flake8', legacy),
});
for (const resolved of [...typedInitialization.settings, typedInitialization.globalSettings]) {
assert.isFalse(resolved.enabled);
assert.deepEqual(resolved.ignorePatterns, []);
assert.notProperty(resolved, 'optionalLabel');
assert.notProperty(resolved, 'envFile');
assert.notProperty(resolved, 'futureOption');
}
assert.deepEqual(Object.keys(typedInitialization).sort(), ['globalSettings', 'settings']);
assert.isTrue(
checkIfConfigurationChanged(
{ affectsConfiguration: (key: string) => key === 'flake8.envFile' },
'flake8',
typed.trackedSettings,
),
);
});

suite('getWorkspaceSettings', () => {
Expand Down
Loading
Loading