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
241 changes: 202 additions & 39 deletions src/extensions/default/TypeScriptSupport/CodeIntelligence.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -62,29 +66,108 @@
// 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() {

Check warning on line 84 in src/extensions/default/TypeScriptSupport/CodeIntelligence.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Move function '_marker' to the outer scope.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ8hqeo7XijY4Qmt_8jK&open=AZ8hqeo7XijY4Qmt_8jK&pullRequest=3003
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({

Check warning on line 103 in src/extensions/default/TypeScriptSupport/CodeIntelligence.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use an object spread instead of `Object.assign` eg: `{ ...foo }`.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ8hqeo7XijY4Qmt_8jL&open=AZ8hqeo7XijY4Qmt_8jL&pullRequest=3003
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) {

Check warning on line 127 in src/extensions/default/TypeScriptSupport/CodeIntelligence.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Move function '_stripJsonComments' to the outer scope.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ8hqeo7XijY4Qmt_8jM&open=AZ8hqeo7XijY4Qmt_8jM&pullRequest=3003
str = str || "";

Check warning on line 128 in src/extensions/default/TypeScriptSupport/CodeIntelligence.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer default parameters over reassignment.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ8hqeo7XijY4Qmt_8jN&open=AZ8hqeo7XijY4Qmt_8jN&pullRequest=3003
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);
}

Check warning on line 147 in src/extensions/default/TypeScriptSupport/CodeIntelligence.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ8hqeo7XijY4Qmt_8jO&open=AZ8hqeo7XijY4Qmt_8jO&pullRequest=3003
});
});
}

/**
* 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<?{fileName:string, json:Object}>}
*/
async function _readManagedConfig(rootPath) {
for (const fileName of EXISTING_CONFIG_FILES) {
const json = await _readConfigJson(rootPath, fileName);
const marker = json && json.autoGeneratedByPhoenixCode;

Check warning on line 163 in src/extensions/default/TypeScriptSupport/CodeIntelligence.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ8hqeo7XijY4Qmt_8jP&open=AZ8hqeo7XijY4Qmt_8jP&pullRequest=3003
if (marker && marker.autoManage === true) {

Check warning on line 164 in src/extensions/default/TypeScriptSupport/CodeIntelligence.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ8hqeo7XijY4Qmt_8jQ&open=AZ8hqeo7XijY4Qmt_8jQ&pullRequest=3003
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).
Expand Down Expand Up @@ -160,29 +243,57 @@
});
}

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;

Check warning on line 261 in src/extensions/default/TypeScriptSupport/CodeIntelligence.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ8hqeo7XijY4Qmt_8jR&open=AZ8hqeo7XijY4Qmt_8jR&pullRequest=3003
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<boolean>} 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;

Check warning on line 291 in src/extensions/default/TypeScriptSupport/CodeIntelligence.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ8hqeo7XijY4Qmt_8jS&open=AZ8hqeo7XijY4Qmt_8jS&pullRequest=3003
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;
}
Expand Down Expand Up @@ -247,11 +358,16 @@
});
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);

Check warning on line 366 in src/extensions/default/TypeScriptSupport/CodeIntelligence.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ8hqeo7XijY4Qmt_8jT&open=AZ8hqeo7XijY4Qmt_8jT&pullRequest=3003
if (ok) {
_showEnabledToast(true); // relabel as TypeScript (also dismisses this toast)
}
});
}());
});
}
$learn.on("click", _learnMore);
Expand Down Expand Up @@ -322,7 +438,9 @@

/**
* 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) {
Expand Down Expand Up @@ -354,10 +472,51 @@
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) {

Check warning on line 490 in src/extensions/default/TypeScriptSupport/CodeIntelligence.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ8hqeo7XijY4Qmt_8jU&open=AZ8hqeo7XijY4Qmt_8jU&pullRequest=3003
return;
}
if (TS_LANGUAGES.indexOf(editor.document.getLanguage().getId()) === -1) {

Check warning on line 493 in src/extensions/default/TypeScriptSupport/CodeIntelligence.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use `.includes()`, rather than `.indexOf()`, when checking for existence.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ8hqeo7XijY4Qmt_8jV&open=AZ8hqeo7XijY4Qmt_8jV&pullRequest=3003
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) {

Check warning on line 506 in src/extensions/default/TypeScriptSupport/CodeIntelligence.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ8hqeo7XijY4Qmt_8jW&open=AZ8hqeo7XijY4Qmt_8jW&pullRequest=3003
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)");
}

/**
Expand All @@ -369,7 +528,7 @@
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;
}
Expand All @@ -385,6 +544,7 @@
_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.
Expand All @@ -397,9 +557,12 @@
});
// 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;
});
5 changes: 4 additions & 1 deletion src/extensions/default/TypeScriptSupport/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,10 @@
*/
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;

Check warning on line 271 in src/extensions/default/TypeScriptSupport/main.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use `.includes()`, rather than `.indexOf()`, when checking for existence.

See more on https://sonarcloud.io/project/issues?id=phcode-dev_phoenix&issues=AZ8hcvnhXijY4Qmt9Q_t&open=AZ8hcvnhXijY4Qmt9Q_t&pullRequest=3003
}

let starting = false;
Expand Down
Loading
Loading