diff --git a/src/extensions/default/TypeScriptSupport/CodeIntelligence.js b/src/extensions/default/TypeScriptSupport/CodeIntelligence.js index 06ff14f231..24784fd604 100644 --- a/src/extensions/default/TypeScriptSupport/CodeIntelligence.js +++ b/src/extensions/default/TypeScriptSupport/CodeIntelligence.js @@ -26,12 +26,16 @@ * Without one it falls back to an "inferred project" scoped to the open file and its imports. * * Rather than nag with a dialog, we make project-wide intelligence the default: the first time a - * JS/TS file is opened in a project that has no config, we silently create a `jsconfig.json` and - * show an unobtrusive toast ("See Config" / "Enable TypeScript" / "Learn more"). We always create a - * `jsconfig.json` - never a `tsconfig.json` - because jsconfig is editor-only (the TS compiler and - * bundlers ignore it, so it can never change a build) yet still scopes `.ts` files project-wide. - * To opt out, the user opens the config and deletes it; we remember (PREF_CREATED) and won't - * recreate it - instead the Problems panel offers a one-click re-enable. + * JS/TS file is opened in a project that has no config, we silently create one - `jsconfig.json` + * for JS projects, `tsconfig.json` (with allowJs + noEmit, so it stays build-safe) when the project + * already contains TypeScript. The generated file self-documents through an + * `autoGeneratedByPhoenixCode` marker whose `autoManage: true` is the contract that lets Phoenix + * keep managing the file silently - e.g. upgrading jsconfig -> tsconfig when TypeScript is later + * added to the project - always preserving the user's compilerOptions. Setting `autoManage: false` + * (or hand-creating a config) makes the file user-owned: Phoenix never touches it again. To opt out + * entirely, the user deletes the config; we remember (PREF_CREATED) and won't recreate it - the + * Problems panel offers a one-click re-enable (the only flow that still shows a toast, as feedback + * for an explicit click). * * Desktop-only (the LSP is desktop-only); never runs in test windows (it would write configs into * fixtures and break tests). @@ -62,29 +66,108 @@ define(function (require, exports, module) { // the jsconfig.json we generate: module, target, moduleResolution, checkJs, jsx, ...). const DOCS_URL = "https://www.typescriptlang.org/tsconfig/"; - // The single config we ever create. Editor-only (build-safe) and scopes .ts as well as .js. - const CONFIG_FILE = "jsconfig.json"; + // The configs we create: jsconfig for JS projects (editor-only, can never change a build), + // tsconfig once the project contains TypeScript (external TS tooling only reads tsconfig). + const JS_CONFIG_FILE = "jsconfig.json"; + const TS_CONFIG_FILE = "tsconfig.json"; // Configs whose presence means the project is already scoped - leave it alone. - const EXISTING_CONFIG_FILES = ["tsconfig.json", "jsconfig.json"]; + const EXISTING_CONFIG_FILES = [TS_CONFIG_FILE, JS_CONFIG_FILE]; // Languages that map to a "TypeScript" label. const TS_LANGUAGES = ["typescript", "tsx"]; + // The self-documenting marker embedded (first, so it's the first thing a user opening the file + // reads) in every config we generate. autoManage is the management contract: while true, Phoenix + // may silently rewrite/upgrade the file (always preserving compilerOptions); the user sets it to + // false to take ownership, after which Phoenix never touches the file again. TypeScript ignores + // unknown top-level keys in ts/jsconfig (custom fields like ts-node's are established practice). + function _marker() { + return { + doc: "Phoenix Code created this file to enable advanced code intelligence for your " + + "project. Delete it to turn that off.", + autoManage: true, + autoManageDoc: "While autoManage is true, Phoenix Code keeps this file up to date " + + "automatically (e.g. upgrades jsconfig.json to tsconfig.json when TypeScript " + + "files are added), preserving your compilerOptions. Set autoManage to false to " + + "stop Phoenix Code from ever modifying this file." + }; + } + // Modern, type-error-free defaults. `jsx: "react"` only affects .jsx/.tsx, harmless elsewhere. - // checkJs flips on TypeScript-grade type checking of JS when the user opts in. - function _jsConfig(checkJs) { + // compilerOptions from an existing managed config being rewritten/upgraded are preserved over + // the defaults; `checkJs` (TypeScript-grade type checking of JS) is applied only when the caller + // passes it, else the preserved value stands. tsconfig.json - unlike editor-only jsconfig - is + // read by real build tools, so it additionally gets allowJs (JS files stay first-class in mixed + // projects) and noEmit (an accidental `npx tsc` must never emit files next to sources). + function _configContent(fileName, existingCompilerOptions, checkJs) { + const compilerOptions = Object.assign({ + module: "esnext", + target: "esnext", + moduleResolution: "bundler", + checkJs: false, + jsx: "react" + }, existingCompilerOptions || {}); + if (checkJs !== undefined) { + compilerOptions.checkJs = !!checkJs; + } + if (fileName === TS_CONFIG_FILE) { + compilerOptions.allowJs = true; + compilerOptions.noEmit = true; + } return { - compilerOptions: { - module: "esnext", - target: "esnext", - moduleResolution: "bundler", - checkJs: !!checkJs, - jsx: "react" - }, + autoGeneratedByPhoenixCode: _marker(), + compilerOptions: compilerOptions, exclude: ["node_modules", "dist", "build"] }; } + // Strip JSONC comments and trailing commas so a config can be JSON.parse'd (same tolerance + // main.js uses for checkJs detection). Good enough for our own + lightly-edited configs; a file + // this can't parse is treated as user-owned and never touched. + function _stripJsonComments(str) { + str = str || ""; + str = str.replace(/\/\*(?:(?!\*\/)[\s\S])*\*\//g, ""); // block comments + str = str.replace(/\/\/[^\n\r]*/g, ""); // line comments + str = str.replace(/,(\s*[}\]])/g, "$1"); // trailing commas + return str; + } + + // Read + tolerant-parse one config file; resolves null when missing/unreadable/unparseable. + function _readConfigJson(rootPath, fileName) { + return new Promise(function (resolve) { + FileSystem.getFileForPath(rootPath + fileName).read(function (err, content) { + if (err || !content) { + resolve(null); + return; + } + try { + resolve(JSON.parse(_stripJsonComments(content))); + } catch (e) { + resolve(null); + } + }); + }); + } + + /** + * The config Phoenix is allowed to manage: the first of tsconfig/jsconfig that parses AND + * carries autoGeneratedByPhoenixCode.autoManage === true. Everything else (hand-written configs, + * autoManage:false, unparseable files, older Phoenix formats without the flag) is user-owned - + * never returned here, never modified. + * @param {string} rootPath + * @return {Promise} + */ + async function _readManagedConfig(rootPath) { + for (const fileName of EXISTING_CONFIG_FILES) { + const json = await _readConfigJson(rootPath, fileName); + const marker = json && json.autoGeneratedByPhoenixCode; + if (marker && marker.autoManage === true) { + return { fileName: fileName, json: json }; + } + } + return null; + } + // Options injected by main.js (kept decoupled from the LSP client wiring). let _options = {}; // Project roots we've already evaluated this session (avoids re-scanning on every file switch). @@ -160,29 +243,57 @@ define(function (require, exports, module) { }); } - function _openConfig() { + function _fileExists(fullPath) { + return new Promise(function (resolve) { + FileSystem.getFileForPath(fullPath).exists(function (err, exists) { + resolve(!err && exists); + }); + }); + } + + // Open the project's config in the editor: the managed one if any, else whichever exists. + async function _openConfig() { const rootPath = _projectRootPath(); - if (rootPath) { - CommandManager.execute(Commands.FILE_OPEN, { fullPath: rootPath + CONFIG_FILE }); + if (!rootPath) { + return; + } + const managed = await _readManagedConfig(rootPath); + let fileName = managed && managed.fileName; + if (!fileName) { + for (const name of EXISTING_CONFIG_FILES) { + if (await _fileExists(rootPath + name)) { + fileName = name; + break; + } + } + } + if (fileName && !_projectChangedSince(rootPath)) { + CommandManager.execute(Commands.FILE_OPEN, { fullPath: rootPath + fileName }); } } /** - * Write the jsconfig (creating or updating it), then restart the server so it re-scopes. - * @param {boolean} checkJs + * Write (create or rewrite) the managed config, preserving the compilerOptions of an existing + * managed config (the autoManage contract), then restart the server so it re-scopes. + * @param {string} fileName - JS_CONFIG_FILE or TS_CONFIG_FILE + * @param {boolean} [checkJs] - set checkJs explicitly; omit to preserve the existing value * @return {Promise} resolves true on success */ - function _writeConfig(checkJs) { + async function _writeConfig(fileName, checkJs) { + const rootPath = _projectRootPath(); + if (!rootPath) { + return false; + } + const managed = await _readManagedConfig(rootPath); + if (_projectChangedSince(rootPath)) { + return false; + } + const existingOptions = managed && managed.json.compilerOptions; + const content = JSON.stringify(_configContent(fileName, existingOptions, checkJs), null, 4) + "\n"; return new Promise(function (resolve) { - const rootPath = _projectRootPath(); - if (!rootPath) { - resolve(false); - return; - } - const content = JSON.stringify(_jsConfig(checkJs), null, 4) + "\n"; - FileSystem.getFileForPath(rootPath + CONFIG_FILE).write(content, function (err) { + FileSystem.getFileForPath(rootPath + fileName).write(content, function (err) { if (err) { - console.error("[TypeScriptSupport] failed to write " + CONFIG_FILE, err); + console.error("[TypeScriptSupport] failed to write " + fileName, err); resolve(false); return; } @@ -247,11 +358,16 @@ define(function (require, exports, module) { }); if ($enableTs) { $enableTs.on("click", function () { - _writeConfig(true).then(function (ok) { + (async function () { + // Flip checkJs on whichever config we manage (it may have been upgraded to + // tsconfig by now); fall back to jsconfig if none is on disk anymore. + const rootPath = _projectRootPath(); + const managed = rootPath && await _readManagedConfig(rootPath); + const ok = await _writeConfig((managed && managed.fileName) || JS_CONFIG_FILE, true); if (ok) { _showEnabledToast(true); // relabel as TypeScript (also dismisses this toast) } - }); + }()); }); } $learn.on("click", _learnMore); @@ -322,7 +438,9 @@ define(function (require, exports, module) { /** * On the first JS/TS file opened in a config-less, non-dismissed project (once per session), - * silently create a jsconfig and surface the unobtrusive toast. + * silently create the config - tsconfig when the project contains TypeScript, else jsconfig. + * Deliberately no toast: the generated file self-documents (autoGeneratedByPhoenixCode.doc) and + * is visible in the file tree / source control. * @param {Editor} editor */ async function _autoEnable(editor) { @@ -354,10 +472,51 @@ define(function (require, exports, module) { if (_projectChangedSince(rootPath)) { return; } - const ok = await _writeConfig(false); - if (ok) { - _showEnabledToast(hasTs); + await _writeConfig(hasTs ? TS_CONFIG_FILE : JS_CONFIG_FILE, false); + } + + // Project roots already checked for a jsconfig -> tsconfig upgrade this session. + const _upgradeChecked = new Set(); + + /** + * Silently upgrade a Phoenix-managed jsconfig.json to tsconfig.json the first time a TypeScript + * file becomes active in the project: TS projects are conventionally scoped by tsconfig (external + * tooling reads only it), and the autoManage contract lets us do this without asking - + * compilerOptions are preserved and the tsconfig carries allowJs + noEmit so it stays build-safe. + * A hand-written tsconfig (or any non-managed jsconfig) means the user owns the setup: hands off. + * @param {Editor} editor + */ + async function _maybeUpgradeToTs(editor) { + if (Phoenix.isTestWindow || !editor || !editor.document) { + return; + } + if (TS_LANGUAGES.indexOf(editor.document.getLanguage().getId()) === -1) { + return; + } + const rootPath = _projectRootPath(); + if (!rootPath || _upgradeChecked.has(rootPath)) { + return; } + _upgradeChecked.add(rootPath); + // Never overwrite an existing tsconfig - if the user added their own, they own the setup. + if (await _fileExists(rootPath + TS_CONFIG_FILE)) { + return; + } + const managed = await _readManagedConfig(rootPath); + if (_projectChangedSince(rootPath) || !managed || managed.fileName !== JS_CONFIG_FILE) { + return; // no managed jsconfig here (none at all, or user-owned) + } + const ok = await _writeConfig(TS_CONFIG_FILE); // checkJs omitted: preserve the existing value + if (!ok || _projectChangedSince(rootPath)) { + return; + } + FileSystem.getFileForPath(rootPath + JS_CONFIG_FILE).unlink(function (err) { + if (err) { + console.error("[TypeScriptSupport] failed to remove " + JS_CONFIG_FILE + " after upgrade", err); + } + }); + console.log("[TypeScriptSupport] upgraded " + JS_CONFIG_FILE + " -> " + TS_CONFIG_FILE + + " (TypeScript detected in project)"); } /** @@ -369,7 +528,7 @@ define(function (require, exports, module) { const editor = EditorManager.getActiveEditor(); const lang = editor && editor.document && editor.document.getLanguage().getId(); const isTs = TS_LANGUAGES.indexOf(lang) !== -1; - _writeConfig(false).then(function (ok) { + _writeConfig(isTs ? TS_CONFIG_FILE : JS_CONFIG_FILE, false).then(function (ok) { if (!ok) { return; } @@ -385,6 +544,7 @@ define(function (require, exports, module) { _options = options || {}; EditorManager.on("activeEditorChange.tsCodeIntel", function (evt, current) { _autoEnable(current); + _maybeUpgradeToTs(current); _updatePanelRow(); // the row depends on the active file's language }); // Also catch switches to non-editor views (image/preview) where activeEditorChange may not fire. @@ -397,9 +557,12 @@ define(function (require, exports, module) { }); // Evaluate the file already showing at startup (the common "restored a JS file" case). _autoEnable(EditorManager.getActiveEditor()); + _maybeUpgradeToTs(EditorManager.getActiveEditor()); _updatePanelRow(); } exports.init = init; exports.promptEnable = promptEnable; + // exposed for unit tests (pure function; the event-driven flow never runs in test windows) + exports._configContent = _configContent; }); diff --git a/src/extensions/default/TypeScriptSupport/main.js b/src/extensions/default/TypeScriptSupport/main.js index af4ee71838..51884f8637 100644 --- a/src/extensions/default/TypeScriptSupport/main.js +++ b/src/extensions/default/TypeScriptSupport/main.js @@ -265,7 +265,10 @@ define(function (require, exports, module) { */ function _isServedLanguageActive() { const editor = EditorManager.getActiveEditor(); - return !!(editor && SUPPORTED_LANGUAGES.indexOf(editor.getLanguageForSelection().getId()) !== -1); + if (!editor) { + return false; + } + return SUPPORTED_LANGUAGES.indexOf(editor.getLanguageForSelection().getId()) !== -1; } let starting = false; diff --git a/src/extensions/default/TypeScriptSupport/unittests.js b/src/extensions/default/TypeScriptSupport/unittests.js index f4a39d3806..35b2b20d7a 100644 --- a/src/extensions/default/TypeScriptSupport/unittests.js +++ b/src/extensions/default/TypeScriptSupport/unittests.js @@ -236,6 +236,99 @@ define(function (require, exports, module) { "close incremental.ts"); }, 90000); + // ----- embedded JavaScript in HTML + +