diff --git a/.vscode/launch.json b/.vscode/launch.json index ad78f01..61055b9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,5 +1,13 @@ { "version": "0.2.0", + "inputs": [ + { + "id": "testWorkspace", + "type": "promptString", + "description": "Absolute path to the folder to open in the Extension Development Host", + "default": "${workspaceFolder}" + } + ], "configurations": [ { "name": "Launch Client", @@ -10,7 +18,7 @@ "--extensionDevelopmentPath=${workspaceRoot}" ], "outFiles": [ - "${workspaceRoot}/out/**/*.js", + "${workspaceRoot}/out/**/*.js" ], "preLaunchTask": { "type": "npm", @@ -18,23 +26,40 @@ } }, { - "name":"Attach to ELS Server", - "type":"node", - "request":"attach", - "port":6004, - "sourceMaps":true, - "outFiles":[ - "${workspaceRoot}/node_modules/@ember-tooling/ember-language-server/lib/**/*.js" + "name": "Launch Client (prompt for folder)", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "--extensionDevelopmentPath=${workspaceRoot}", + "${input:testWorkspace}" + ], + "outFiles": [ + "${workspaceRoot}/out/**/*.js" ], - "restart":true, - "smartStep":true - } + "preLaunchTask": { + "type": "npm", + "script": "watch" + } + }, + { + "name": "Attach to ELS Server", + "type": "node", + "request": "attach", + "port": 6004, + "sourceMaps": true, + "outFiles": [ + "${workspaceRoot}/node_modules/@ember-tooling/ember-language-server/lib/**/*.js" + ], + "restart": true, + "smartStep": true + } ], - "compounds": [ - { - "name": "Client + Server", - "configurations": ["Launch Client", "Attach"], //"Attach" is in ember-language-server project - "stopAll": true, - } - ] + "compounds": [ + { + "name": "Client + Server", + "configurations": ["Launch Client", "Attach"], + "stopAll": true + } + ] } diff --git a/package.json b/package.json index df9cb0d..1319ecb 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,13 @@ "onLanguage:typescript", "onLanguage:glimmer-js", "onLanguage:glimmer-ts", - "workspaceContains:ember-cli-build.js", + "workspaceContains:**/ember-cli-build.js", + "workspaceContains:**/ember-cli-build.cjs", + "workspaceContains:**/node_modules/ember-source/package.json", + "workspaceContains:**/node_modules/ember-template-lint/package.json", + "workspaceContains:**/node_modules/ember-template-imports/package.json", + "workspaceContains:**/node_modules/content-tag/package.json", + "workspaceContains:**/node_modules/@glimmerx/core/package.json", "onCommand:els.runInEmberCLI" ], "contributes": { @@ -101,6 +107,11 @@ "type": "boolean", "default": true, "description": "Folding range provider for .hbs files, disabling may improve startup performance for very large projects" + }, + "els.detection.forceEnable": { + "type": "boolean", + "default": false, + "description": "Bypass Ember project detection and always start the language server. Useful for exotic monorepo layouts (e.g. pnpm setups) where automatic detection fails." } } }, @@ -148,7 +159,7 @@ }, "devDependencies": { "@types/mocha": "^2.2.33", - "@types/node": "^6.0.52", + "@types/node": "^14.18.63", "@types/vscode": "1.60.0", "@typescript-eslint/eslint-plugin": "^5.33.1", "@typescript-eslint/parser": "^5.33.1", diff --git a/src/extension.ts b/src/extension.ts index d6e79d9..4d48d2b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,6 +11,7 @@ import { workspace, ExtensionContext, StatusBarItem, + OutputChannel, window, commands, languages, @@ -18,7 +19,7 @@ import { StatusBarAlignment, Uri, } from 'vscode'; -import { isEmberCliProject, emberLikeProject } from './workspace-utils'; +import { isEmberProject } from './workspace-utils'; import { LanguageClient, LanguageClientOptions, @@ -30,6 +31,7 @@ import { import { provideCodeLenses } from './lenses'; let ExtStatusBarItem: StatusBarItem; let ExtServerDebugBarItem: StatusBarItem; +let ExtOutputChannel: OutputChannel; export async function activate(context: ExtensionContext) { // The server is implemented in node const serverModule = path.join(context.extensionPath, './start-server.js'); @@ -49,10 +51,11 @@ export async function activate(context: ExtensionContext) { }, }; - if (!(await isEmberCliProject())) { - if (!(await emberLikeProject())) { - return; - } + ExtOutputChannel = window.createOutputChannel('Ember Language Server'); + context.subscriptions.push(ExtOutputChannel); + + if (!(await isEmberProject(ExtOutputChannel))) { + return; } const syncExtensions = ['js', 'ts', 'hbs', 'gts', 'gjs']; @@ -70,7 +73,7 @@ export async function activate(context: ExtensionContext) { 'javascript', 'typescript', ], - outputChannelName: 'Ember Language Server', + outputChannel: ExtOutputChannel, revealOutputChannelOn: RevealOutputChannelOn.Never, initializationOptions: { editor: 'vscode' }, synchronize: { diff --git a/src/workspace-utils.ts b/src/workspace-utils.ts index df8aac3..ab85e0e 100644 --- a/src/workspace-utils.ts +++ b/src/workspace-utils.ts @@ -1,21 +1,257 @@ -import { workspace } from 'vscode'; +import { workspace, Uri, WorkspaceFolder, OutputChannel } from 'vscode'; -export async function emberLikeProject(): Promise { - const emberCliBuildFile = await workspace.findFiles( - '**/node_modules/{glimmer-lite-core,@glimmerx/core,content-tag,ember-template-lint,ember-source,ember-template-imports}/package.json', - '**/{dist,tmp,.git,.cache}/**', - 1 +/** + * Packages whose presence indicates an Ember-like project. If any of these + * resolve from a workspace folder, we activate the language server. + */ +const EMBER_MARKER_PACKAGES = [ + 'ember-source', + 'ember-template-lint', + 'ember-template-imports', + 'content-tag', + 'glimmer-lite-core', + '@glimmerx/core', +] as const; + +/** + * File names at the root of a workspace folder that strongly indicate an + * Ember CLI project. + */ +const EMBER_CLI_BUILD_FILES = [ + 'ember-cli-build.js', + 'ember-cli-build.cjs', + 'ember-cli-build.mjs', + 'ember-cli-build.ts', +] as const; + +const DEPENDENCY_FIELDS = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +] as const; + +/** + * Primary entry point. Returns true if the language server should start for + * the current workspace. Iterates all workspace folders and short-circuits on + * the first positive signal. + */ +export async function isEmberProject( + outputChannel: OutputChannel +): Promise { + const config = workspace.getConfiguration('els'); + const forceEnable = config.get('detection.forceEnable', false); + if (forceEnable) { + return true; + } + + const folders: ReadonlyArray = + workspace.workspaceFolders ?? []; + if (folders.length === 0) { + return false; + } + + const results = await Promise.all( + folders.map((folder) => detectInFolder(folder.uri)) ); - return emberCliBuildFile.length > 0; + if (!results.some((r) => r === true)) { + const paths = folders.map((f) => f.uri.fsPath).join(', '); + outputChannel.appendLine( + `ELS: no Ember markers found in ${paths}; set \`els.detection.forceEnable\` to override.` + ); + return false; + } + + return true; } -export async function isEmberCliProject(): Promise { - const emberCliBuildFile = await workspace.findFiles( - '**/ember-cli-build.{js,cjs}', - '**/{dist,tmp,node_modules,.git,.cache}/**', - 1 - ); +async function detectInFolder(folderUri: Uri): Promise { + // Tier 1: cheap package.json / build-file inspection. + if (await checkBuildFile(folderUri)) { + return true; + } + + if (await checkPackageJsonDeps(folderUri)) { + return true; + } + + // Tier 2: Node resolver. Handles pnpm (incl. package-level opens), + // npm-hoisted, and yarn classic transparently. Not available in the + // web-worker build; guarded by a try/catch around the dynamic import. + if (await checkViaResolver(folderUri)) { + return true; + } + + // Tier 3: explicit ancestor walk via workspace.fs. Belt-and-braces for + // folders without a package.json or exotic resolver failures. + if (await checkViaAncestorWalk(folderUri)) { + return true; + } + + return false; +} + +/** + * Read and parse a package.json at `/package.json`. Returns undefined if + * the file is missing, unreadable, not valid JSON, or not a JSON object. + */ +async function readPackageJson( + dirUri: Uri +): Promise | undefined> { + const pkgUri = Uri.joinPath(dirUri, 'package.json'); + + let raw: Uint8Array; + try { + raw = await workspace.fs.readFile(pkgUri); + } catch { + return undefined; + } + + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder('utf-8').decode(raw)); + } catch { + return undefined; + } + + if (!parsed || typeof parsed !== 'object') { + return undefined; + } + + return parsed as Record; +} + +async function checkPackageJsonDeps(folderUri: Uri): Promise { + const pkg = await readPackageJson(folderUri); + if (!pkg) return false; + + for (const field of DEPENDENCY_FIELDS) { + const deps = pkg[field]; + if (!deps || typeof deps !== 'object') continue; + for (const marker of EMBER_MARKER_PACKAGES) { + if (marker in (deps as Record)) { + return true; + } + } + } + + return false; +} + +async function checkBuildFile(folderUri: Uri): Promise { + for (const name of EMBER_CLI_BUILD_FILES) { + const uri = Uri.joinPath(folderUri, name); + try { + await workspace.fs.stat(uri); + return true; + } catch { + // not present, try next + } + } + return false; +} + +async function checkViaResolver(folderUri: Uri): Promise { + // createRequire is a Node.js built-in and is not available in the + // web-worker/browser build. Import it lazily so the module still loads + // in that environment; if the import fails we fall through gracefully. + let createRequire: ((filename: string) => NodeRequire) | undefined; + try { + ({ createRequire } = await import('module')); + } catch { + return false; + } + if (!createRequire) return false; + + let anchoredRequire: NodeRequire; + try { + // The anchor file need not exist; createRequire only uses the parent + // directory as the resolution base. + anchoredRequire = createRequire(Uri.joinPath(folderUri, 'noop.js').fsPath); + } catch { + return false; + } + + for (const marker of EMBER_MARKER_PACKAGES) { + try { + anchoredRequire.resolve(`${marker}/package.json`); + return true; + } catch { + // not resolvable from this anchor, try next + } + } + + return false; +} + +async function checkViaAncestorWalk(folderUri: Uri): Promise { + const seen = new Set(); + let dirUri = folderUri; + // -1 = not yet seen a workspace root. Once set to a non-negative value, + // counts how many more ancestors we'll visit before stopping. + let stepsAfterWorkspaceRoot = -1; + + // Cap the walk to avoid pathological filesystems. 40 levels is deeper than + // any realistic project tree. + for (let i = 0; i < 40; i += 1) { + const key = dirUri.toString(); + if (seen.has(key)) break; + seen.add(key); + + for (const marker of EMBER_MARKER_PACKAGES) { + const candidate = Uri.joinPath( + dirUri, + 'node_modules', + marker, + 'package.json' + ); + try { + await workspace.fs.stat(candidate); + return true; + } catch { + // not here, keep looking + } + } + + if (stepsAfterWorkspaceRoot < 0) { + // Not yet at a workspace root: once we find one, allow one more + // ancestor above it. pnpm-workspace.yaml or a package.json with a + // "workspaces" field marks the top of the workspace; going one level + // above handles layouts that nest the repo inside another tooling + // directory. + if (await isWorkspaceRoot(dirUri)) { + stepsAfterWorkspaceRoot = 0; + } + } else if (stepsAfterWorkspaceRoot === 0) { + break; + } else { + stepsAfterWorkspaceRoot -= 1; + } + + const parentPath = Uri.joinPath(dirUri, '..').fsPath; + const parentUri = Uri.file(parentPath); + if (parentUri.toString() === dirUri.toString()) break; + dirUri = parentUri; + } + + return false; +} + +/** + * Returns true if `dir` is the root of a monorepo workspace. Checks for: + * - pnpm: presence of a pnpm-workspace.yaml file + * - npm/yarn: a package.json with a "workspaces" field + */ +async function isWorkspaceRoot(dirUri: Uri): Promise { + const pnpmWorkspace = Uri.joinPath(dirUri, 'pnpm-workspace.yaml'); + try { + await workspace.fs.stat(pnpmWorkspace); + return true; + } catch { + // fall through + } - return emberCliBuildFile.length > 0; + const pkg = await readPackageJson(dirUri); + return !!pkg && 'workspaces' in pkg; } diff --git a/yarn.lock b/yarn.lock index 3a0b630..e9922ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -660,10 +660,10 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-16.10.2.tgz#5764ca9aa94470adb4e1185fe2e9f19458992b2e" integrity sha512-zCclL4/rx+W5SQTzFs9wyvvyCwoK9QtBpratqz2IYJ3O8Umrn0m3nsTv0wQBk9sRGpvUe9CwPDrQFB10f1FIjQ== -"@types/node@^6.0.52": - version "6.14.9" - resolved "https://registry.yarnpkg.com/@types/node/-/node-6.14.9.tgz#733583e21ef0eab85a9737dfafbaa66345a92ef0" - integrity sha512-leP/gxHunuazPdZaCvsCefPQxinqUDsCxCR5xaDUrY2MkYxQRFZZwU5e7GojyYsGB7QVtCi7iVEl/hoFXQYc+w== +"@types/node@^14.18.63": + version "14.18.63" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.63.tgz#1788fa8da838dbb5f9ea994b834278205db6ca2b" + integrity sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ== "@types/vscode@1.60.0": version "1.60.0"