Skip to content
Draft
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
12 changes: 8 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@
},
"devDependencies": {
"@babel/core": "^7.23.6",
"@typescript-eslint/parser": "^7.1.0",
"@typescript-eslint/scope-manager": "^7.1.0",
"@typescript-eslint/visitor-keys": "^7.1.0",
"@typescript-eslint/parser": "^8.0.0",
"@typescript-eslint/scope-manager": "^8.0.0",
"@typescript-eslint/visitor-keys": "^8.0.0",
"concurrently": "^8.2.2",
"eslint": "^8.0.1",
"eslint-config-prettier": "^9.1.0",
Expand All @@ -64,16 +64,20 @@
"vitest": "^1.2.2"
},
"peerDependencies": {
"@glint/ember-tsc": ">= 1.1.0",
"@typescript-eslint/parser": "*"
},
"peerDependenciesMeta": {
"@glint/ember-tsc": {
"optional": true
},
"@typescript-eslint/parser": {
"optional": true
}
},
"packageManager": "pnpm@10.21.0",
"engines": {
"node": ">=16.0.0"
"node": ">=22.12.0"
},
"pnpm": {
"overrides": {
Expand Down
1,083 changes: 462 additions & 621 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

162 changes: 143 additions & 19 deletions src/parser/gjs-gts-parser.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
import { createRequire } from 'node:module';
import tsconfigUtils from '@typescript-eslint/tsconfig-utils';
import { registerParsedFile } from '../preprocessor/noop.js';
import { patchTs, replaceExtensions, syncMtsGtsSourceFiles, typescriptParser } from './ts-patch.js';
import { buildGlimmerVisitors } from './transforms.js';
import {
patchTs,
replaceExtensions,
syncMtsGtsSourceFiles,
typescriptParser,
ts,
} from './ts-patch.js';
import {
buildGlimmerVisitors,
preprocessGlimmerTemplatesFromCharOffsets,
convertAst,
} from './transforms.js';
import {
isGlintAvailable,
getGlintConfig,
glintRewriteModule,
buildTemplateInfoFromGlint,
} from './glint-utils.js';
import { remapAstPositions, remapTokens } from './remap.js';
import { toTree } from 'ember-estree';
import * as eslintScope from 'eslint-scope';

Expand Down Expand Up @@ -128,6 +145,102 @@ function getAllowJs(options) {
return false;
}

/**
* Parse using Glint's transform for full type-aware template support.
* Glint transforms templates into __glintDSL__ calls that TS understands,
* then we remap AST positions back to original source and splice in Glimmer AST.
*/
function parseWithGlint(code, options, transformedModule) {
const filePath = options.filePath;

// Get transformed TS code and replace .gts→.mts imports
let tsCode = transformedModule.transformedContents;
if (options.project || options.projectService) {
tsCode = replaceExtensions(tsCode);
}

// Parse the transformed code with TS parser (positions in transformed-space)
const result = typescriptParser.parseForESLint(tsCode, {
...options,
ranges: true,
extraFileExtensions: ['.gts', '.gjs'],
filePath,
});

// Build template infos from Glint's correlatedSpans
const glintTemplateInfos = buildTemplateInfoFromGlint(transformedModule);

// Always remap positions even if no templates — Glint may have changed code length
// for non-template spans (e.g., directive placeholders)
const { templateSpans } = remapAstPositions(
result.ast,
result.visitorKeys,
transformedModule.correlatedSpans,
code
);

// Remap tokens
result.ast.tokens = remapTokens(
result.ast.tokens,
transformedModule.correlatedSpans,
templateSpans,
code
);

if (!glintTemplateInfos.length) {
return result;
}

// Preprocess Glimmer templates (parse to Glimmer AST with correct positions)
const preprocessedResult = preprocessGlimmerTemplatesFromCharOffsets(glintTemplateInfos, code);
const { templateVisitorKeys } = preprocessedResult;
const visitorKeys = { ...result.visitorKeys, ...templateVisitorKeys };
result.isTypescript = true;

// Splice Glimmer AST into the remapped TS AST (matchByRangeOnly because
// Glint produces different node types than transformForLint)
convertAst(result, preprocessedResult, { matchByRangeOnly: true });

// remapAstPositions marks nodes entirely inside a template span with
// __glintOrphaned=true (it skips remapping them since they will be replaced).
// After convertAst replaces the static block with the Glimmer AST, those nodes
// are gone from the tree — ESLint never traverses them so .parent stays
// undefined. Scope entries pointing to them crash rules like no-unused-vars.
// Remove those entries; they are Glint DSL artifacts, not user-authored code.
if (result.scopeManager && templateSpans.length > 0) {
const isOrphanedNode = (node) => node?.__glintOrphaned === true;
for (const scope of result.scopeManager.scopes) {
// Filter references whose identifier nodes are Glint-orphaned
const isOrphanedRef = (ref) => isOrphanedNode(ref.identifier);
scope.references = scope.references.filter((ref) => !isOrphanedRef(ref));
if (scope.through) {
scope.through = scope.through.filter((ref) => !isOrphanedRef(ref));
}
// Filter variables whose definition identifiers are Glint-orphaned
// (Glint DSL vars like __glintY__ are defined inside the template span)
const removedVarNames = new Set();
scope.variables = scope.variables.filter((variable) => {
const isOrphaned = variable.defs.some((def) => isOrphanedNode(def.name));
if (isOrphaned) removedVarNames.add(variable.name);
return !isOrphaned;
});
for (const name of removedVarNames) {
scope.set?.delete(name);
}
// Also clean up orphaned references on remaining variables
for (const variable of scope.variables) {
variable.references = variable.references.filter((ref) => !isOrphanedRef(ref));
}
}
}

if (result.services?.program) {
syncMtsGtsSourceFiles(result.services.program);
}

return { ...result, visitorKeys };
}

/**
* @type {import('eslint').ParserModule}
*/
Expand All @@ -137,15 +250,39 @@ export const meta = {
};

export function parseForESLint(code, options) {
const allowGjsWasSet = options.allowGjs !== undefined;
const allowGjs = allowGjsWasSet ? options.allowGjs : getAllowJs(options);
let actualAllowGjs;
const allowGjs = options.allowGjs !== undefined ? options.allowGjs : getAllowJs(options);
// Only patch TypeScript if we actually need it.
if (options.programs || options.projectService || options.project) {
({ allowGjs: actualAllowGjs } = patchTs({ allowGjs }));
patchTs({ allowGjs });
}
registerParsedFile(options.filePath);

// Type-aware .gts linting requires Glint. @glint/ember-tsc must be installed and
// tsconfig must have an explicit "glint" key. Without Glint, the legacy
// transformForLint path cannot produce correct type information for templates.
const isGts = options.filePath.endsWith('.gts');
const isTypeAware = Boolean(options.project || options.projectService || options.programs);
if (isGts && isTypeAware) {
if (!isGlintAvailable()) {
throw new Error(
'[ember-eslint-parser] @glint/ember-tsc is required for type-aware linting of .gts files. ' +
'Install it as a dependency of your project.'
);
}
const glintConfig = getGlintConfig(options.filePath);
if (!glintConfig) {
throw new Error(
'[ember-eslint-parser] No Glint environment found for this .gts file. ' +
'Ensure @glint/ember-tsc is configured in your tsconfig.'
);
}
const glintTransform = glintRewriteModule(code, options.filePath, ts, glintConfig);
if (glintTransform) {
return parseWithGlint(code, options, glintTransform);
}
// glintTransform === null means no templates — fall through to normal TS parse
}

const isTypescript = options.filePath.endsWith('.gts') || options.filePath.endsWith('.ts');
let useTypescript = true;

Expand Down Expand Up @@ -202,19 +339,6 @@ export function parseForESLint(code, options) {
if (!result.scopeManager) result.scopeManager = scopeManager;

if (result.services?.program) {
const programAllowJs = result.services.program.getCompilerOptions?.()?.allowJs;
if (
!allowGjsWasSet &&
programAllowJs !== undefined &&
actualAllowGjs !== undefined &&
actualAllowGjs !== programAllowJs
) {
// eslint-disable-next-line no-console
console.warn(
'[ember-eslint-parser] allowJs does not match the actual program. Consider setting allowGjs explicitly.\n' +
` Current: ${allowGjs}, Program: ${programAllowJs}`
);
}
syncMtsGtsSourceFiles(result.services.program);
}

Expand Down
113 changes: 113 additions & 0 deletions src/parser/glint-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import fs from 'node:fs';
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);

let glintAvailable = false;
let rewriteModule, ConfigLoader;

try {
({ rewriteModule } = require('@glint/ember-tsc/transform/index'));
({ ConfigLoader } = require('@glint/ember-tsc/config/index'));
glintAvailable = true;
} catch {
// @glint/ember-tsc not installed
}

const configLoader = glintAvailable ? new ConfigLoader() : null;

/**
* @returns {boolean}
*/
export function isGlintAvailable() {
return glintAvailable;
}

/**
* Returns true if the tsconfig at tsconfigPath has an explicit "glint" section.
* Glint's ConfigLoader can auto-detect environments without explicit config, so
* we use this check to avoid silently activating Glint for projects that don't
* opt in.
* @param {string} tsconfigPath
* @returns {boolean}
*/
function tsconfigHasGlintSection(tsconfigPath) {
try {
// TypeScript supports JSONC (comments + trailing commas), so use a permissive
// parse that strips comments before JSON.parse.
const raw = fs.readFileSync(tsconfigPath, 'utf8');
// Strip single-line and block comments, trailing commas
const stripped = raw
.replace(/\/\/.*$/gm, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/,(\s*[}\]])/g, '$1');
const parsed = JSON.parse(stripped);
return Object.prototype.hasOwnProperty.call(parsed, 'glint');
} catch {
return false;
}
}

/**
* Loads and caches GlintConfig for the project containing filePath.
* Returns null if @glint/ember-tsc is not available or no glint
* environment is configured in the project's tsconfig.
* @param {string} filePath
* @returns {import('@glint/ember-tsc').GlintConfig | null}
*/
export function getGlintConfig(filePath) {
if (!configLoader) return null;
try {
const config = configLoader.configForFile(filePath);
if (!config || config.environment.names.length === 0) return null;
// Glint's ConfigLoader can auto-detect environments when @glint/ember-tsc is
// installed without the user explicitly configuring it in their tsconfig.
// Only activate Glint processing when the tsconfig has an explicit "glint" key.
if (!tsconfigHasGlintSection(config.configPath)) return null;
return config;
} catch {
return null;
}
}

/**
* Rewrites a .gts/.gjs module using Glint's template-to-TypeScript transform.
* Returns TransformedModule or null if no templates found / transform not needed.
* @param {string} code - file contents
* @param {string} filePath - absolute file path
* @param {*} ts - TypeScript instance
* @param {import('@glint/ember-tsc').GlintConfig} config - Glint config
* @returns {{ transformedContents: string } | null}
*/
export function glintRewriteModule(code, filePath, ts, config) {
if (!rewriteModule) return null;
return rewriteModule(ts, { script: { filename: filePath, contents: code } }, config.environment);
}

/**
* Build template info objects from a Glint TransformedModule's correlatedSpans.
* Returns template ranges in character (UTF-16) offsets suitable for
* preprocessGlimmerTemplatesFromCharOffsets.
*
* @param {object} transformedModule - Glint TransformedModule
* @returns {Array<{ range: [number, number] }>}
*/
export function buildTemplateInfoFromGlint(transformedModule) {
const result = [];
const seen = new Set();
for (const span of transformedModule.correlatedSpans) {
if (!span.glimmerAstMapping) continue;

const fullStart = span.originalStart;
// Deduplicate: multiple spans can map to the same original template
if (seen.has(fullStart)) continue;
seen.add(fullStart);

const fullEnd = span.originalStart + span.originalLength;

result.push({
range: [fullStart, fullEnd],
});
}
return result;
}
Loading
Loading