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
415 changes: 373 additions & 42 deletions src/common/inlineScript/metadata.ts

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions src/common/localize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,50 @@ export namespace InlineScriptStrings {
export const updatePythonAndPylanceExtensions = l10n.t(
'The environment for this script was created. Update the Python and Pylance extensions for the full inline script experience.',
);

export const diagnosticSource = l10n.t('Python Environments');

export const unterminatedBlock = l10n.t(
"This '# /// script' block is missing its closing '# ///' marker, so its inline script metadata is ignored.",
);
export const multipleBlocks = l10n.t(
"A script may contain only one '# /// script' block. Remove this block or merge it into the first one.",
);
export function invalidContentLine(line: string): string {
const found = line.trim();
if (found.length === 0) {
return l10n.t(
"Lines inside a '# /// script' block must be exactly '#' or start with '# '. This line is blank; use '#' for a blank metadata line.",
);
}
return l10n.t(
"Lines inside a '# /// script' block must be exactly '#' or start with '# '. Found: {0}",
found,
);
}
export function invalidBlockMarker(marker: string): string {
return l10n.t(
"'{0}' is not a valid inline script marker because of trailing whitespace. Markers must be exactly '# /// script' and '# ///'.",
marker,
);
}
export function invalidToml(detail: string): string {
return l10n.t('The inline script metadata is not valid TOML: {0}', detail);
}
export function invalidFieldType(field: string): string {
switch (field) {
case 'requires-python':
return l10n.t("Inline script metadata: 'requires-python' must be a string, for example '>=3.11'.");
case 'dependencies':
return l10n.t(
"Inline script metadata: 'dependencies' must be an array of strings, for example ['requests'].",
);
case 'tool':
return l10n.t("Inline script metadata: 'tool' must be a table.");
default:
return l10n.t("Inline script metadata: '{0}' has the wrong type.", field);
}
}
}

export namespace Interpreter {
Expand Down
8 changes: 8 additions & 0 deletions src/common/workspace.apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ export function onDidSaveTextDocument(
return workspace.onDidSaveTextDocument(listener, thisArgs, disposables);
}

export function onDidCloseTextDocument(
listener: (e: TextDocument) => any,
thisArgs?: any,
disposables?: Disposable[],
): Disposable {
return workspace.onDidCloseTextDocument(listener, thisArgs, disposables);
}

export function onDidChangeTextDocument(
listener: (e: TextDocumentChangeEvent) => any,
thisArgs?: any,
Expand Down
5 changes: 5 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
import { PythonEnvironmentManagers } from './features/envManagers';
import { EnvVarManager, PythonEnvVariableManager } from './features/execution/envVariableManager';
import { latchInlineScriptFeatureActivation } from './features/inlineScript/activation';
import { registerInlineScriptDiagnostics } from './features/inlineScript/diagnostics';
import { InlineScriptLazyDetector } from './features/inlineScript/lazyDetector';
import { registerInlineScriptUx } from './features/inlineScript/setupEnvironment';
import {
Expand Down Expand Up @@ -229,6 +230,10 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
inlineScriptLazyDetector.activate();
context.subscriptions.push(inlineScriptLazyDetector);

if (inlineScriptFeatureActivation.enabled) {
context.subscriptions.push(registerInlineScriptDiagnostics());
}

setPythonApi(envManagers, projectManager, projectCreators, terminalManager, envVarManager);
const api = await getPythonApi();
const sysPythonManager = createDeferred<SysPythonManager>();
Expand Down
229 changes: 229 additions & 0 deletions src/features/inlineScript/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import {
Diagnostic,
DiagnosticCollection,
DiagnosticSeverity,
Disposable,
languages,
Range,
TextDocument,
Uri,
} from 'vscode';
import {
InlineScriptMetadataProblem,
parseInlineScriptMetadata,
sliceHeaderBytes,
} from '../../common/inlineScript/metadata';
import { getInlineScriptRoutingKey } from '../../common/inlineScript/routingRegistry';
import { InlineScriptStrings } from '../../common/localize';
import { traceVerbose } from '../../common/logging';
import { createSimpleDebounce, SimpleDebounce } from '../../common/utils/debounce';
import { isSameOrParentPath } from '../../common/utils/pathUtils';
import {
getOpenTextDocuments,
onDidChangeTextDocument,
onDidCloseTextDocument,
onDidDeleteFiles,
onDidOpenTextDocument,
onDidRenameFiles,
onDidSaveTextDocument,
} from '../../common/workspace.apis';

const VALIDATION_DEBOUNCE_MS = 300;

const DIAGNOSTIC_COLLECTION_NAME = 'python-envs-inline-script';

/** Publishes diagnostics for malformed PEP 723 inline script metadata, validating the live buffer. */
export class InlineScriptDiagnosticsPublisher implements Disposable {
private readonly subscriptions: Disposable[] = [];
// `createSimpleDebounce` owns one timer, so a shared instance would let
// edits in one file cancel another's pending validation.
private readonly pending = new Map<string, { debounce: SimpleDebounce; document: TextDocument }>();
private readonly published = new Map<string, Uri>();
private disposed = false;

constructor(private readonly collection: DiagnosticCollection) {}

/** Documents open at activation are replayed via `setImmediate`, because `onLanguage:python` fires after editors are restored. */
public activate(): void {
this.subscriptions.push(
onDidOpenTextDocument((doc) => this.validate(doc)),
onDidSaveTextDocument((doc) => this.validate(doc)),
onDidChangeTextDocument((e) => this.scheduleValidation(e.document)),
onDidCloseTextDocument((doc) => this.clear(doc.uri)),
onDidDeleteFiles((e) => e.files.forEach((uri) => this.clearTree(uri))),
onDidRenameFiles((e) =>
e.files.forEach((file) => {
this.clearTree(file.oldUri);
this.revalidateIfOpen(file.newUri);
}),
),
);
const handle = setImmediate(() => this.replayOpenDocuments());
this.subscriptions.push(new Disposable(() => clearImmediate(handle)));
}

public dispose(): void {
this.disposed = true;
this.subscriptions.forEach((s) => s.dispose());
this.subscriptions.length = 0;
this.pending.forEach((entry) => entry.debounce.dispose());
this.pending.clear();
this.published.clear();
this.collection.dispose();
}

private replayOpenDocuments(): void {
if (this.disposed) {
return;
}
const docs = getOpenTextDocuments().filter((doc) => shouldValidateUri(doc.uri));
traceVerbose(`inlineScriptDiagnostics: activation replay over ${docs.length} candidate .py document(s)`);
for (const doc of docs) {
this.validate(doc);
}
}

private scheduleValidation(document: TextDocument): void {
if (this.disposed || !shouldValidateUri(document.uri)) {
return;
}
const key = document.uri.toString();
const existing = this.pending.get(key);
if (existing) {
existing.document = document;
existing.debounce.trigger();
return;
}
const entry = {
document,
debounce: createSimpleDebounce(VALIDATION_DEBOUNCE_MS, () => {
const queued = this.pending.get(key);
this.pending.delete(key);
if (queued) {
this.validate(queued.document);
}
}),
};
this.pending.set(key, entry);
entry.debounce.trigger();
}

private validate(document: TextDocument): void {
if (this.disposed || !shouldValidateUri(document.uri)) {
return;
}
this.cancelPending(document.uri);

const uri = document.uri;
const result = parseInlineScriptMetadata(sliceHeaderBytes(document.getText()), uri.fsPath);
const problems = result.kind === 'none' ? [] : result.problems;
if (problems.length === 0) {
this.clear(uri);
return;
}

const diagnostics = problems.map((problem) => toDiagnostic(document, problem));
this.collection.set(uri, diagnostics);
this.published.set(uri.toString(), uri);
traceVerbose(
`inlineScriptDiagnostics: published ${diagnostics.length} problem(s) for ${uri.fsPath}: ` +
problems.map((p) => p.code).join(', '),
);
}

private revalidateIfOpen(uri: Uri): void {
if (this.disposed || !shouldValidateUri(uri)) {
return;
}
const key = uri.toString();
const doc = getOpenTextDocuments().find((d) => d.uri.toString() === key);
if (doc) {
this.validate(doc);
}
}

private clear(uri: Uri): void {
this.cancelPending(uri);
const key = uri.toString();
if (this.published.delete(key)) {
this.collection.delete(uri);
}
}

private clearTree(uri: Uri): void {
this.clear(uri);
if (uri.scheme !== 'file') {
return;
}
// Pending entries have never published, so `published` alone would leave a
// debounced validation to fire for a file that no longer exists.
const tracked = [
...this.published.values(),
...Array.from(this.pending.values(), (entry) => entry.document.uri),
];
for (const candidate of tracked) {
if (candidate.scheme === 'file' && isSameOrParentPath(uri.fsPath, candidate.fsPath)) {
this.clear(candidate);
}
}
}

private cancelPending(uri: Uri): void {
Comment thread
StellaHuang95 marked this conversation as resolved.
const key = uri.toString();
const entry = this.pending.get(key);
if (entry) {
entry.debounce.dispose();
this.pending.delete(key);
}
}
}

/** Whether inline script metadata is meaningful for `uri` — a local `.py` file. */
export function shouldValidateUri(uri: Uri): boolean {
return getInlineScriptRoutingKey(uri) !== undefined;
}

function toDiagnostic(document: TextDocument, problem: InlineScriptMetadataProblem): Diagnostic {
const range = new Range(
document.positionAt(problem.sourceRange.start),
document.positionAt(problem.sourceRange.end),
);
const diagnostic = new Diagnostic(range, messageFor(problem), severityFor(problem));
diagnostic.source = InlineScriptStrings.diagnosticSource;
diagnostic.code = problem.code;
return diagnostic;
}

function messageFor(problem: InlineScriptMetadataProblem): string {
switch (problem.code) {
case 'unterminated-block':
return InlineScriptStrings.unterminatedBlock;
case 'multiple-blocks':
return InlineScriptStrings.multipleBlocks;
case 'invalid-content-line':
return InlineScriptStrings.invalidContentLine(problem.detail ?? '');
case 'invalid-block-marker':
return InlineScriptStrings.invalidBlockMarker(problem.detail ?? '');
case 'invalid-toml':
return InlineScriptStrings.invalidToml(problem.detail ?? '');
case 'invalid-field-type':
return InlineScriptStrings.invalidFieldType(problem.detail ?? '');
default:
return InlineScriptStrings.unterminatedBlock;
}
}

function severityFor(problem: InlineScriptMetadataProblem): DiagnosticSeverity {
return problem.severity === 'warning' ? DiagnosticSeverity.Warning : DiagnosticSeverity.Error;
}

export function registerInlineScriptDiagnostics(): Disposable {
const publisher = new InlineScriptDiagnosticsPublisher(
languages.createDiagnosticCollection(DIAGNOSTIC_COLLECTION_NAME),
);
publisher.activate();
return publisher;
}
Loading
Loading