From 312ff10b65c14779e0779982b20041609e585d3f Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Sat, 18 Apr 2026 11:36:06 +0100 Subject: [PATCH 1/2] feat: enhance Ember project detection --- .vscode/launch.json | 61 +++++++---- package.json | 11 +- src/extension.ts | 8 +- src/workspace-utils.ts | 233 ++++++++++++++++++++++++++++++++++++++--- yarn.lock | 8 +- 5 files changed, 278 insertions(+), 43 deletions(-) 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..31e2b12 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,9 @@ "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", "onCommand:els.runInEmberCLI" ], "contributes": { @@ -101,6 +103,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 +155,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..2115d75 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -18,7 +18,7 @@ import { StatusBarAlignment, Uri, } from 'vscode'; -import { isEmberCliProject, emberLikeProject } from './workspace-utils'; +import { isEmberProject } from './workspace-utils'; import { LanguageClient, LanguageClientOptions, @@ -49,10 +49,8 @@ export async function activate(context: ExtensionContext) { }, }; - if (!(await isEmberCliProject())) { - if (!(await emberLikeProject())) { - return; - } + if (!(await isEmberProject())) { + return; } const syncExtensions = ['js', 'ts', 'hbs', 'gts', 'gjs']; diff --git a/src/workspace-utils.ts b/src/workspace-utils.ts index df8aac3..9f87865 100644 --- a/src/workspace-utils.ts +++ b/src/workspace-utils.ts @@ -1,21 +1,226 @@ -import { workspace } from 'vscode'; +import * as path from 'path'; +import { createRequire } from 'module'; +import { workspace, Uri, WorkspaceFolder } 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(): 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.fsPath)) ); + return results.some((r) => r === true); +} + +async function detectInFolder(folderFsPath: string): Promise { + // Tier 1: cheap package.json / build-file inspection. + if (await checkBuildFile(folderFsPath)) { + return true; + } + + if (await checkPackageJsonDeps(folderFsPath)) { + return true; + } + + // Tier 2: Node resolver. Handles pnpm (incl. package-level opens), + // npm-hoisted, and yarn classic transparently. + if (checkViaResolver(folderFsPath)) { + return true; + } - return emberCliBuildFile.length > 0; + // Tier 3: explicit ancestor walk via workspace.fs. Belt-and-braces for + // folders without a package.json or exotic resolver failures. + if (await checkViaAncestorWalk(folderFsPath)) { + return true; + } + + return false; } -export async function isEmberCliProject(): Promise { - const emberCliBuildFile = await workspace.findFiles( - '**/ember-cli-build.{js,cjs}', - '**/{dist,tmp,node_modules,.git,.cache}/**', - 1 - ); +/** + * 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( + dir: string +): Promise | undefined> { + const pkgUri = Uri.file(path.join(dir, '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(folderFsPath: string): Promise { + const pkg = await readPackageJson(folderFsPath); + if (!pkg) return false; + + for (const field of DEPENDENCY_FIELDS) { + const deps = pkg[field]; + if (!deps || typeof deps !== 'object') continue; + const depNames = Object.keys(deps as Record); + for (const marker of EMBER_MARKER_PACKAGES) { + if (depNames.indexOf(marker) !== -1) { + return true; + } + } + } + + return false; +} + +async function checkBuildFile(folderFsPath: string): Promise { + for (const name of EMBER_CLI_BUILD_FILES) { + const uri = Uri.file(path.join(folderFsPath, name)); + try { + await workspace.fs.stat(uri); + return true; + } catch { + // not present, try next + } + } + return false; +} + +function checkViaResolver(folderFsPath: string): boolean { + let anchoredRequire: NodeRequire; + try { + // The anchor file need not exist; createRequire only uses the parent + // directory as the resolution base. + anchoredRequire = createRequire(path.join(folderFsPath, 'noop.js')); + } 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(folderFsPath: string): Promise { + const seen = new Set(); + let dir = folderFsPath; + // -1 = not yet seen a monorepo root. Once set to a non-negative value, + // counts how many more ancestors we'll visit before stopping. + let stepsAfterMonorepoRoot = -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) { + if (seen.has(dir)) break; + seen.add(dir); + + for (const marker of EMBER_MARKER_PACKAGES) { + const candidate = Uri.file( + path.join(dir, 'node_modules', marker, 'package.json') + ); + try { + await workspace.fs.stat(candidate); + return true; + } catch { + // not here, keep looking + } + } + + if (stepsAfterMonorepoRoot < 0) { + // Not yet at a monorepo root: allow one more ancestor after we hit one. + // 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 isMonorepoRoot(dir)) { + stepsAfterMonorepoRoot = 1; + } + } else if (stepsAfterMonorepoRoot === 0) { + break; + } else { + stepsAfterMonorepoRoot -= 1; + } + + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + + return false; +} + +async function isMonorepoRoot(dir: string): Promise { + const pnpmWorkspace = Uri.file(path.join(dir, 'pnpm-workspace.yaml')); + try { + await workspace.fs.stat(pnpmWorkspace); + return true; + } catch { + // fall through + } - return emberCliBuildFile.length > 0; + const pkg = await readPackageJson(dir); + 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" From d5867d71a8e317750557f96d07c32bff32b3a401 Mon Sep 17 00:00:00 2001 From: Liam Potter Date: Thu, 23 Apr 2026 18:01:24 +0100 Subject: [PATCH 2/2] address PR comments --- package.json | 4 ++ src/extension.ts | 9 ++- src/workspace-utils.ts | 121 ++++++++++++++++++++++++++--------------- 3 files changed, 87 insertions(+), 47 deletions(-) diff --git a/package.json b/package.json index 31e2b12..1319ecb 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,10 @@ "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": { diff --git a/src/extension.ts b/src/extension.ts index 2115d75..4d48d2b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,6 +11,7 @@ import { workspace, ExtensionContext, StatusBarItem, + OutputChannel, window, commands, languages, @@ -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,7 +51,10 @@ export async function activate(context: ExtensionContext) { }, }; - if (!(await isEmberProject())) { + ExtOutputChannel = window.createOutputChannel('Ember Language Server'); + context.subscriptions.push(ExtOutputChannel); + + if (!(await isEmberProject(ExtOutputChannel))) { return; } @@ -68,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 9f87865..ab85e0e 100644 --- a/src/workspace-utils.ts +++ b/src/workspace-utils.ts @@ -1,6 +1,4 @@ -import * as path from 'path'; -import { createRequire } from 'module'; -import { workspace, Uri, WorkspaceFolder } from 'vscode'; +import { workspace, Uri, WorkspaceFolder, OutputChannel } from 'vscode'; /** * Packages whose presence indicates an Ember-like project. If any of these @@ -38,7 +36,9 @@ const DEPENDENCY_FIELDS = [ * the current workspace. Iterates all workspace folders and short-circuits on * the first positive signal. */ -export async function isEmberProject(): Promise { +export async function isEmberProject( + outputChannel: OutputChannel +): Promise { const config = workspace.getConfiguration('els'); const forceEnable = config.get('detection.forceEnable', false); if (forceEnable) { @@ -52,30 +52,40 @@ export async function isEmberProject(): Promise { } const results = await Promise.all( - folders.map((folder) => detectInFolder(folder.uri.fsPath)) + folders.map((folder) => detectInFolder(folder.uri)) ); - return results.some((r) => r === true); + + 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; } -async function detectInFolder(folderFsPath: string): Promise { +async function detectInFolder(folderUri: Uri): Promise { // Tier 1: cheap package.json / build-file inspection. - if (await checkBuildFile(folderFsPath)) { + if (await checkBuildFile(folderUri)) { return true; } - if (await checkPackageJsonDeps(folderFsPath)) { + if (await checkPackageJsonDeps(folderUri)) { return true; } // Tier 2: Node resolver. Handles pnpm (incl. package-level opens), - // npm-hoisted, and yarn classic transparently. - if (checkViaResolver(folderFsPath)) { + // 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(folderFsPath)) { + if (await checkViaAncestorWalk(folderUri)) { return true; } @@ -87,9 +97,9 @@ async function detectInFolder(folderFsPath: string): Promise { * the file is missing, unreadable, not valid JSON, or not a JSON object. */ async function readPackageJson( - dir: string + dirUri: Uri ): Promise | undefined> { - const pkgUri = Uri.file(path.join(dir, 'package.json')); + const pkgUri = Uri.joinPath(dirUri, 'package.json'); let raw: Uint8Array; try { @@ -112,16 +122,15 @@ async function readPackageJson( return parsed as Record; } -async function checkPackageJsonDeps(folderFsPath: string): Promise { - const pkg = await readPackageJson(folderFsPath); +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; - const depNames = Object.keys(deps as Record); for (const marker of EMBER_MARKER_PACKAGES) { - if (depNames.indexOf(marker) !== -1) { + if (marker in (deps as Record)) { return true; } } @@ -130,9 +139,9 @@ async function checkPackageJsonDeps(folderFsPath: string): Promise { return false; } -async function checkBuildFile(folderFsPath: string): Promise { +async function checkBuildFile(folderUri: Uri): Promise { for (const name of EMBER_CLI_BUILD_FILES) { - const uri = Uri.file(path.join(folderFsPath, name)); + const uri = Uri.joinPath(folderUri, name); try { await workspace.fs.stat(uri); return true; @@ -143,12 +152,23 @@ async function checkBuildFile(folderFsPath: string): Promise { return false; } -function checkViaResolver(folderFsPath: string): boolean { +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(path.join(folderFsPath, 'noop.js')); + anchoredRequire = createRequire(Uri.joinPath(folderUri, 'noop.js').fsPath); } catch { return false; } @@ -165,22 +185,26 @@ function checkViaResolver(folderFsPath: string): boolean { return false; } -async function checkViaAncestorWalk(folderFsPath: string): Promise { +async function checkViaAncestorWalk(folderUri: Uri): Promise { const seen = new Set(); - let dir = folderFsPath; - // -1 = not yet seen a monorepo root. Once set to a non-negative value, + 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 stepsAfterMonorepoRoot = -1; + 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) { - if (seen.has(dir)) break; - seen.add(dir); + const key = dirUri.toString(); + if (seen.has(key)) break; + seen.add(key); for (const marker of EMBER_MARKER_PACKAGES) { - const candidate = Uri.file( - path.join(dir, 'node_modules', marker, 'package.json') + const candidate = Uri.joinPath( + dirUri, + 'node_modules', + marker, + 'package.json' ); try { await workspace.fs.stat(candidate); @@ -190,30 +214,37 @@ async function checkViaAncestorWalk(folderFsPath: string): Promise { } } - if (stepsAfterMonorepoRoot < 0) { - // Not yet at a monorepo root: allow one more ancestor after we hit one. - // 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 isMonorepoRoot(dir)) { - stepsAfterMonorepoRoot = 1; + 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 (stepsAfterMonorepoRoot === 0) { + } else if (stepsAfterWorkspaceRoot === 0) { break; } else { - stepsAfterMonorepoRoot -= 1; + stepsAfterWorkspaceRoot -= 1; } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; + const parentPath = Uri.joinPath(dirUri, '..').fsPath; + const parentUri = Uri.file(parentPath); + if (parentUri.toString() === dirUri.toString()) break; + dirUri = parentUri; } return false; } -async function isMonorepoRoot(dir: string): Promise { - const pnpmWorkspace = Uri.file(path.join(dir, 'pnpm-workspace.yaml')); +/** + * 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; @@ -221,6 +252,6 @@ async function isMonorepoRoot(dir: string): Promise { // fall through } - const pkg = await readPackageJson(dir); + const pkg = await readPackageJson(dirUri); return !!pkg && 'workspaces' in pkg; }