diff --git a/package.json b/package.json index 5358f3d..046edc2 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "activationEvents": [ "onLanguage:yaml", "workspaceContains:melos.yaml", + "workspaceContains:pubspec.yaml", "onCommand:melos.bootstrap", "onCommand:melos.clean", "onCommand:melos.runScript", @@ -100,6 +101,16 @@ "command": "melos.bootstrap", "when": "resourceFilename == melos.yaml", "group": "navigation" + }, + { + "command": "melos.showPackageGraph", + "when": "resourceFilename == pubspec.yaml", + "group": "navigation" + }, + { + "command": "melos.bootstrap", + "when": "resourceFilename == pubspec.yaml", + "group": "navigation" } ] } diff --git a/src/code-lenses.ts b/src/code-lenses.ts index 275fd24..a7cb6ff 100644 --- a/src/code-lenses.ts +++ b/src/code-lenses.ts @@ -3,41 +3,75 @@ import { MelosRunScriptCommandArgs } from './commands' import { debug } from './logging' import { vscodeRangeFromNode } from './utils/yaml-utils' import { + melosYamlFile, MelosWorkspaceConfig, parseMelosWorkspaceConfig, + parsePubspecWithMelosConfig, + pubspecYamlFile, } from './workspace-config' export function registerMelosYamlCodeLenseProvider( context: vscode.ExtensionContext ) { + const provider = new MelosCodeLensProvider() + context.subscriptions.push( vscode.languages.registerCodeLensProvider( { language: 'yaml', pattern: '**/melos.yaml' }, - new MelosYamlCodeLenseProvider() + provider + ) + ) + + context.subscriptions.push( + vscode.languages.registerCodeLensProvider( + { language: 'yaml', pattern: '**/pubspec.yaml' }, + provider ) ) } -class MelosYamlCodeLenseProvider implements vscode.CodeLensProvider { +class MelosCodeLensProvider implements vscode.CodeLensProvider { async provideCodeLenses(document: vscode.TextDocument) { const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri) - const melosConfig = parseMelosWorkspaceConfig(document.getText()) + if (!workspaceFolder) { + return [] + } + + const melosConfig = this.resolveConfig(document, workspaceFolder) + if (!melosConfig) { + return [] + } - return [ - ...this.buildRunScriptCodeLenses(melosConfig, workspaceFolder, document), - ] + return this.buildRunScriptCodeLenses(melosConfig, workspaceFolder, document) + } + + private resolveConfig( + document: vscode.TextDocument, + workspaceFolder: vscode.WorkspaceFolder + ): MelosWorkspaceConfig | null { + const fileName = document.uri.path.split('/').pop() + + if (fileName === melosYamlFile) { + return parseMelosWorkspaceConfig(document.getText()) + } + + if (fileName === pubspecYamlFile) { + // Only provide lenses for the root pubspec.yaml, not package-level files. + const rootUri = vscode.Uri.joinPath(workspaceFolder.uri, pubspecYamlFile) + if (document.uri.toString() !== rootUri.toString()) { + return null + } + return parsePubspecWithMelosConfig(document.getText()) + } + + return null } private buildRunScriptCodeLenses( melosConfig: MelosWorkspaceConfig, - workspaceFolder: vscode.WorkspaceFolder | undefined, + workspaceFolder: vscode.WorkspaceFolder, document: vscode.TextDocument ) { - if (!workspaceFolder) { - // We need a workspace folder to run scripts. - return [] - } - debug( `Providing 'Run script' CodeLenses in '${workspaceFolder.name}' folder`, melosConfig.scripts.map((script) => script.name.value) diff --git a/src/commands.ts b/src/commands.ts index c30184e..8e589af 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -91,7 +91,9 @@ function runScriptCommandHandler(context: vscode.ExtensionContext) { const melosConfig = await loadMelosWorkspaceConfig(context, workspaceFolder) if (!melosConfig) { - vscode.window.showErrorMessage('No melos.yaml file found.') + vscode.window.showErrorMessage( + 'No Melos configuration found. Add a melos.yaml file or a melos: key in pubspec.yaml.' + ) return } diff --git a/src/melos-workspace.ts b/src/melos-workspace.ts index 5938537..3d61ca1 100644 --- a/src/melos-workspace.ts +++ b/src/melos-workspace.ts @@ -1,8 +1,12 @@ import * as vscode from 'vscode' import { debug, info } from './logging' -import { fileExists } from './utils/fs-utils' +import { fileExists, readOptionalFile } from './utils/fs-utils' import { isOpenWorkspaceFolder } from './utils/vscode-utils' -import { melosYamlFile } from './workspace-config' +import { + isDartWorkspacePubspec, + melosYamlFile, + pubspecYamlFile, +} from './workspace-config' /** * API for the currently opened Melos workspaces. @@ -86,6 +90,46 @@ export async function initMelosWorkspaces(context: vscode.ExtensionContext) { } }) ) + + // Watch root pubspec.yaml files for creation/deletion (Dart workspace, Melos 7+). + // Content changes are not watched here since they fire on every package save. + const pubspecYamlWatcher = vscode.workspace.createFileSystemWatcher( + `**/${pubspecYamlFile}`, + false, + true, + false + ) + context.subscriptions.push(pubspecYamlWatcher) + + context.subscriptions.push( + pubspecYamlWatcher.onDidCreate(async (uri) => { + debug(`pubspec.yaml onDidCreate: ${uri}`) + + const folder = vscode.workspace.getWorkspaceFolder(uri) + if (folder) { + const pubspecAtRoot = vscode.Uri.joinPath(folder.uri, pubspecYamlFile) + if ( + uri.toString() === pubspecAtRoot.toString() && + (await isMelosWorkspace(folder)) + ) { + addMelosWorkspace(folder) + } + } + }) + ) + + context.subscriptions.push( + pubspecYamlWatcher.onDidDelete(async (uri) => { + debug(`pubspec.yaml onDidDelete: ${uri}`) + + const folder = vscode.workspace.getWorkspaceFolder(uri) + if (folder) { + if (!(await isMelosWorkspace(folder))) { + removeMelosWorkspace(folder) + } + } + }) + ) } const workspaceFolders: vscode.WorkspaceFolder[] = [] @@ -122,8 +166,19 @@ function removeMelosWorkspace(folder: vscode.WorkspaceFolder) { }) } -export async function isMelosWorkspace(folder: vscode.WorkspaceFolder) { - return fileExists(vscode.Uri.joinPath(folder.uri, melosYamlFile)) +export async function isMelosWorkspace( + folder: vscode.WorkspaceFolder +): Promise { + // Old-style: melos.yaml at workspace root (Melos <7) + if (await fileExists(vscode.Uri.joinPath(folder.uri, melosYamlFile))) { + return true + } + + // New-style: pubspec.yaml with `workspace:` key (Dart workspace, Melos 7+) + const pubspecContent = await readOptionalFile( + vscode.Uri.joinPath(folder.uri, pubspecYamlFile) + ) + return pubspecContent !== null && isDartWorkspacePubspec(pubspecContent.toString()) } async function getMelosWorkspaceFolders() { diff --git a/src/test/suite/workspace-config.test.ts b/src/test/suite/workspace-config.test.ts index 0901514..d540ce9 100644 --- a/src/test/suite/workspace-config.test.ts +++ b/src/test/suite/workspace-config.test.ts @@ -1,5 +1,9 @@ import * as assert from 'assert' -import { parseMelosWorkspaceConfig } from '../../workspace-config' +import { + isDartWorkspacePubspec, + parseMelosWorkspaceConfig, + parsePubspecWithMelosConfig, +} from '../../workspace-config' suite('MelosWorkspaceConfig', () => { suite('parseMelosWorkspaceConfig', () => { @@ -181,5 +185,125 @@ scripts: flutter: undefined, }) }) + + test('sets configSource to melos.yaml', () => { + const config = parseMelosWorkspaceConfig(`scripts:\n a: echo hi\n`) + assert.strictEqual(config.configSource, 'melos.yaml') + }) + }) +}) + +suite('isDartWorkspacePubspec', () => { + test('returns true when workspace: key is present', () => { + assert.strictEqual( + isDartWorkspacePubspec(` +name: my_workspace +environment: + sdk: '>=3.6.0 <4.0.0' +workspace: + - packages/a + - packages/b +`), + true + ) + }) + + test('returns false when workspace: key is absent', () => { + assert.strictEqual( + isDartWorkspacePubspec(` +name: my_package +environment: + sdk: '>=3.0.0 <4.0.0' +`), + false + ) + }) + + test('returns false for empty content', () => { + assert.strictEqual(isDartWorkspacePubspec(''), false) + }) +}) + +suite('parsePubspecWithMelosConfig', () => { + test('returns null when melos: key is absent', () => { + const config = parsePubspecWithMelosConfig(` +name: my_workspace +workspace: + - packages/a +`) + assert.strictEqual(config, null) + }) + + test('parses scripts from melos: key', () => { + const config = parsePubspecWithMelosConfig(` +name: my_workspace +workspace: + - packages/a +melos: + scripts: + build: flutter build apk + test: flutter test +`) + + assert.ok(config) + assert.strictEqual(config.configSource, 'pubspec.yaml') + assert.strictEqual(config.scripts.length, 2) + + const build = config.scripts.find((s) => s.name.value === 'build')! + assert.strictEqual(build.name.value, 'build') + assert.strictEqual(build.run?.value, 'flutter build apk') + + const test = config.scripts.find((s) => s.name.value === 'test')! + assert.strictEqual(test.name.value, 'test') + assert.strictEqual(test.run?.value, 'flutter test') + }) + + test('parses melos exec script from pubspec.yaml', () => { + const config = parsePubspecWithMelosConfig(` +name: my_workspace +workspace: + - packages/a +melos: + scripts: + analyze: melos exec -- dart analyze +`) + + assert.ok(config) + assert.deepStrictEqual(config.scripts[0].run?.melosExec, { + options: [], + command: 'dart analyze', + }) + }) + + test('returns empty scripts when melos: has no scripts key', () => { + const config = parsePubspecWithMelosConfig(` +name: my_workspace +workspace: + - packages/a +melos: + command: + bootstrap: + usePubspecOverrides: true +`) + + assert.ok(config) + assert.strictEqual(config.scripts.length, 0) + }) + + test('preserves yaml node positions for CodeLens', () => { + const config = parsePubspecWithMelosConfig( + `name: my_workspace +workspace: + - packages/a +melos: + scripts: + build: flutter build apk +` + ) + + assert.ok(config) + assert.strictEqual(config.scripts.length, 1) + // The node position must be non-null to allow CodeLens rendering + assert.ok(config.scripts[0].name.yamlNode) }) }) diff --git a/src/workspace-config.ts b/src/workspace-config.ts index ffc1ee9..5e9106b 100644 --- a/src/workspace-config.ts +++ b/src/workspace-config.ts @@ -7,6 +7,7 @@ import { MelosPackageFilters } from './package-filters' import { readOptionalFile } from './utils/fs-utils' export const melosYamlFile = 'melos.yaml' +export const pubspecYamlFile = 'pubspec.yaml' // https://regex101.com/r/idiNSJ/1 const melosExecRegex = /^\s*melos\s*exec/ @@ -22,6 +23,11 @@ export interface MelosWorkspaceConfig { */ readonly yamlDoc: Document + /** + * The source file from which the configuration was parsed. + */ + readonly configSource: 'melos.yaml' | 'pubspec.yaml' + /** * Configuration for Melos commands. */ @@ -127,6 +133,31 @@ export function parseMelosWorkspaceConfig(text: string): MelosWorkspaceConfig { return melosWorkspaceConfigFromYamlDoc(doc) } +/** + * Parses a pubspec.yaml string and returns a {@link MelosWorkspaceConfig} if + * the file contains a `melos:` key (Dart workspace style, Melos 7+). + * + * Returns null if the file does not contain melos configuration. + */ +export function parsePubspecWithMelosConfig( + text: string +): MelosWorkspaceConfig | null { + const doc = parseDocument(text, { keepCstNodes: true }) + return pubspecMelosConfigFromYamlDoc(doc) +} + +/** + * Returns true if the given pubspec.yaml content represents a Dart workspace + * root (i.e. has a `workspace:` key), indicating Melos 7+ support. + */ +export function isDartWorkspacePubspec(text: string): boolean { + try { + return parseDocument(text).has('workspace') + } catch { + return false + } +} + /** * Returns whether the given workspace configuration was created from a valid * file. @@ -155,24 +186,36 @@ export async function loadMelosWorkspaceConfig( context: vscode.ExtensionContext, folder: vscode.WorkspaceFolder ): Promise { - const configFile = await readOptionalFile( + // Try melos.yaml first (traditional approach, Melos <7) + const melosYamlContent = await readOptionalFile( vscode.Uri.joinPath(folder.uri, melosYamlFile) ) - - if (configFile === null) { - return null + if (melosYamlContent !== null) { + const config = parseMelosWorkspaceConfig(melosYamlContent.toString()) + if (await isMelosWorkspaceConfigValid(context, config)) { + info(`Loaded valid ${melosYamlFile} from '${folder.name}' folder`) + } else { + warn(`Loaded invalid ${melosYamlFile} from '${folder.name}' folder`) + showInvalidMelosYamlMessage(folder) + } + return config } - const config = parseMelosWorkspaceConfig(configFile.toString()) - - if (await isMelosWorkspaceConfigValid(context, config)) { - info(`Loaded valid ${melosYamlFile} from '${folder.name}' folder`) - } else { - warn(`Loaded invalid ${melosYamlFile} from '${folder.name}' folder`) - showInvalidMelosYamlMessage(folder) + // Fall back to pubspec.yaml with melos: key (Dart workspace approach, Melos 7+) + const pubspecContent = await readOptionalFile( + vscode.Uri.joinPath(folder.uri, pubspecYamlFile) + ) + if (pubspecContent !== null) { + const config = parsePubspecWithMelosConfig(pubspecContent.toString()) + if (config) { + info( + `Loaded melos config from ${pubspecYamlFile} in '${folder.name}' folder` + ) + return config + } } - return config + return null } function showInvalidMelosYamlMessage(folder: vscode.WorkspaceFolder) { @@ -184,11 +227,26 @@ function showInvalidMelosYamlMessage(folder: vscode.WorkspaceFolder) { function melosWorkspaceConfigFromYamlDoc(doc: Document): MelosWorkspaceConfig { return { yamlDoc: doc, + configSource: 'melos.yaml', command: doc.toJSON()['command'], scripts: melosScriptsConfigsFromYaml(doc.get('scripts')), } } +function pubspecMelosConfigFromYamlDoc( + doc: Document +): MelosWorkspaceConfig | null { + if (!doc.has('melos')) { + return null + } + return { + yamlDoc: doc, + configSource: 'pubspec.yaml', + command: (doc.toJSON() as any)?.melos?.command, + scripts: melosScriptsConfigsFromYaml(doc.getIn(['melos', 'scripts'])), + } +} + function melosScriptsConfigsFromYaml(value: any): MelosScriptConfig[] { if (!(value instanceof YAMLMap)) { return []