From df34cb4cfce9200eb119281937288ac24ee01906 Mon Sep 17 00:00:00 2001
From: patrick <pa.trickjlp@gmail.com>
Date: Thu, 21 Mar 2024 10:11:04 +0100
Subject: [PATCH] pass correct scope

---
 package.json           |   1 -
 src/plugin.ts          | 122 +++++++++++++++++++++++++++++++----------
 src/scope-locals.ts    |  10 +++-
 test/transform.test.ts |  36 +++++++-----
 4 files changed, 124 insertions(+), 45 deletions(-)

diff --git a/package.json b/package.json
index 3e9fe1f..06d1172 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,6 @@
   "scripts": {
     "prepare": "tsc",
     "build": "tsc",
-    "pretest": "tsc",
     "lint": "eslint --cache --ext .ts .",
     "test": "vitest --run",
     "clean": "git clean -d -f -x src __tests__"
diff --git a/src/plugin.ts b/src/plugin.ts
index 9153649..91a4dc8 100644
--- a/src/plugin.ts
+++ b/src/plugin.ts
@@ -7,7 +7,13 @@ import { JSUtils, ExtendedPluginBuilder } from './js-utils';
 import type { EmberTemplateCompiler, PreprocessOptions } from './ember-template-compiler';
 import { LegacyModuleName } from './public-types';
 import { ScopeLocals } from './scope-locals';
-import { ASTPluginBuilder, getTemplateLocals, preprocess, print } from '@glimmer/syntax';
+import {
+  ASTPluginBuilder,
+  ASTPluginEnvironment,
+  NodeVisitor,
+  preprocess,
+  print,
+} from '@glimmer/syntax';
 
 export * from './public-types';
 
@@ -372,11 +378,10 @@ function runtimeErrorIIFE(babel: typeof Babel, replacements: { ERROR_MESSAGE: st
 
 function buildScopeLocals(
   userTypedOptions: Record<string, unknown>,
-  formatOptions: ModuleConfig,
-  templateContent: string
+  formatOptions: ModuleConfig
 ): ScopeLocals {
   if (formatOptions.rfc931Support && userTypedOptions.eval) {
-    return discoverLocals(templateContent);
+    return new ScopeLocals();
   } else if (userTypedOptions.scope) {
     return userTypedOptions.scope as ScopeLocals;
   } else {
@@ -384,21 +389,69 @@ function buildScopeLocals(
   }
 }
 
-function discoverLocals(templateContent: string): ScopeLocals {
-  // this is wrong, but the right thing is unreleased in
-  // https://github.com/glimmerjs/glimmer-vm/pull/1421, so for the moment I'm
-  // sticking with the exact behavior that ember-templates-imports has.
-  //
-  // (the reason it's wrong is that the correct answer depends on not just the
-  // template, but the ambient javascript scope. Anything in locals needs to win
-  // over ember keywords. Otherwise we can never introduce new keywords.)
-  let scopeLocals = new ScopeLocals();
-  for (let local of getTemplateLocals(templateContent)) {
-    if (local.match(/^[$A-Z_][0-9A-Z_$]*$/i)) {
-      scopeLocals.add(local);
+function discoverLocals<EnvSpecificOptions>(
+  jsPath: NodePath<t.Expression>,
+  state: State<EnvSpecificOptions>,
+  locals: string[],
+  scope: ScopeLocals
+) {
+  function add(name: string) {
+    if (locals.includes(name)) return;
+    locals.push(name);
+  }
+
+  let astScope: string[] = [];
+  function isInScope(name: string) {
+    return astScope.flat().includes(name);
+  }
+
+  function isInJsScope(name: string) {
+    if (jsPath.scope.getBinding(name)) return true;
+    if (['this', 'globalThis'].includes(name)) return true;
+    if (scope.locals.includes(name)) return true;
+    if (state.originalImportedNames.has(name)) {
+      return true;
     }
+    return false;
   }
-  return scopeLocals;
+
+  return (_env: ASTPluginEnvironment): { name: string; visitor: NodeVisitor } => {
+    return {
+      name: 'discover-locals',
+      visitor: {
+        All: {
+          enter(_node, path) {
+            const blockParams = (path.parentNode as any)?.blockParams;
+            if (blockParams && ['children', 'body'].includes(path.parentKey!)) {
+              astScope.push(blockParams);
+            }
+          },
+          exit(_node, path) {
+            const blockParams = (path.parentNode as any)?.blockParams;
+            if (blockParams && ['children', 'body'].includes(path.parentKey!)) {
+              const i = astScope.indexOf(blockParams);
+              astScope.splice(i, 1);
+            }
+          },
+        },
+        PathExpression(node) {
+          if (
+            node.head.type === 'VarHead' &&
+            !isInScope(node.head.name) &&
+            isInJsScope(node.head.name)
+          ) {
+            add(node.head.name);
+          }
+        },
+        ElementNode(node) {
+          const name = node.tag.split('.')[0];
+          if (!isInScope(name) && isInJsScope(name)) {
+            add(name);
+          }
+        },
+      },
+    };
+  };
 }
 
 function buildPrecompileOptions<EnvSpecificOptions>(
@@ -437,6 +490,9 @@ function buildPrecompileOptions<EnvSpecificOptions>(
     },
   };
 
+  const locals: string[] = [];
+  output.plugins?.ast?.push(discoverLocals(target, state, locals, scope));
+
   for (let [key, value] of Object.entries(userTypedOptions)) {
     if (key !== 'scope') {
       // `scope` in the user-facing API becomes `locals` in the low-level
@@ -445,7 +501,7 @@ function buildPrecompileOptions<EnvSpecificOptions>(
     }
   }
 
-  output.locals = scope.locals;
+  output.locals = locals;
 
   if (config.rfc931Support) {
     output.strictMode = true;
@@ -483,7 +539,7 @@ function insertCompiledTemplate<EnvSpecificOptions>(
   backingClass: NodePath<Parameters<typeof t.callExpression>[1][number]> | undefined
 ) {
   let t = babel.types;
-  let scopeLocals = buildScopeLocals(userTypedOptions, config, template);
+  let scopeLocals = buildScopeLocals(userTypedOptions, config);
   let options = buildPrecompileOptions(
     babel,
     target,
@@ -513,6 +569,8 @@ function insertCompiledTemplate<EnvSpecificOptions>(
     configFile: false,
   }) as t.File;
 
+  scopeLocals.locals.length = 0;
+  scopeLocals.locals.push(...options.locals!);
   ensureImportedNames(target, scopeLocals, state.util, state.originalImportedNames);
   remapIdentifiers(precompileResultAST, babel, scopeLocals);
 
@@ -560,7 +618,7 @@ function insertTransformedTemplate<EnvSpecificOptions>(
   backingClass: NodePath<Parameters<typeof t.callExpression>[1][number]> | undefined
 ) {
   let t = babel.types;
-  let scopeLocals = buildScopeLocals(userTypedOptions, formatOptions, template);
+  let scopeLocals = buildScopeLocals(userTypedOptions, formatOptions);
   let options = buildPrecompileOptions(
     babel,
     target,
@@ -574,14 +632,14 @@ function insertTransformedTemplate<EnvSpecificOptions>(
   let transformed = print(ast, { entityEncoding: 'raw' });
   if (target.isCallExpression()) {
     (target.get('arguments.0') as NodePath<t.Node>).replaceWith(t.stringLiteral(transformed));
-    if (!scopeLocals.isEmpty()) {
-      if (!formatOptions.enableScope) {
-        maybePruneImport(state.util, target.get('callee'));
-        target.set('callee', precompileTemplate(state.util, target));
-      }
-      ensureImportedNames(target, scopeLocals, state.util, state.originalImportedNames);
-      updateScope(babel, target, scopeLocals);
+    if (!formatOptions.enableScope) {
+      maybePruneImport(state.util, target.get('callee'));
+      target.set('callee', precompileTemplate(state.util, target));
     }
+    scopeLocals.locals.length = 0;
+    scopeLocals.locals.push(...options.locals!);
+    ensureImportedNames(target, scopeLocals, state.util, state.originalImportedNames);
+    updateScope(babel, target, scopeLocals);
 
     if (formatOptions.rfc931Support === 'polyfilled') {
       maybePruneImport(state.util, target.get('callee'));
@@ -615,6 +673,8 @@ function insertTransformedTemplate<EnvSpecificOptions>(
         t.callExpression(precompileTemplate(state.util, target), [t.stringLiteral(transformed)])
       )[0];
       ensureImportedNames(newCall, scopeLocals, state.util, state.originalImportedNames);
+      scopeLocals.locals.length = 0;
+      scopeLocals.locals.push(...options.locals!);
       updateScope(babel, newCall, scopeLocals);
     } else {
       (target.get('quasi').get('quasis.0') as NodePath<t.TemplateElement>).replaceWith(
@@ -635,6 +695,7 @@ function templateFactoryConfig(opts: NormalizedOpts) {
 
 function buildScope(babel: typeof Babel, locals: ScopeLocals) {
   let t = babel.types;
+
   return t.arrowFunctionExpression(
     [],
     t.objectExpression(
@@ -656,13 +717,16 @@ function updateScope(babel: typeof Babel, target: NodePath<t.CallExpression>, lo
     });
     if (scope) {
       scope.set('value', buildScope(babel, locals));
-    } else {
+      if (locals.isEmpty()) {
+        scope.remove();
+      }
+    } else if (!locals.isEmpty()) {
       secondArg.pushContainer(
         'properties',
         t.objectProperty(t.identifier('scope'), buildScope(babel, locals))
       );
     }
-  } else {
+  } else if (!locals.isEmpty()) {
     target.pushContainer(
       'arguments',
       t.objectExpression([t.objectProperty(t.identifier('scope'), buildScope(babel, locals))])
diff --git a/src/scope-locals.ts b/src/scope-locals.ts
index b283dc9..e002d02 100644
--- a/src/scope-locals.ts
+++ b/src/scope-locals.ts
@@ -31,7 +31,15 @@ export class ScopeLocals {
   }
 
   entries() {
-    return Object.entries(this.#mapping);
+    let mapping: Record<string, string> = {};
+    this.locals.forEach((name) => {
+      mapping[name] = this.mapping[name] || name;
+    });
+    return Object.entries(mapping);
+  }
+
+  get mapping() {
+    return this.#mapping;
   }
 
   add(key: string, value?: string) {
diff --git a/test/transform.test.ts b/test/transform.test.ts
index ee9c16e..5e74982 100644
--- a/test/transform.test.ts
+++ b/test/transform.test.ts
@@ -12,7 +12,7 @@ import sinon from 'sinon';
 import { ExtendedPluginBuilder } from '../src/js-utils';
 import * as precompile from './mock-precompile';
 import { Preprocessor } from 'content-tag';
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
 
 describe('htmlbars-inline-precompile', function () {
   // eslint-disable-next-line @typescript-eslint/no-var-requires
@@ -1115,6 +1115,7 @@ describe('htmlbars-inline-precompile', function () {
         const template = precompileTemplate("<Message @text={{two}} />", {
           moduleName: 'customModuleName',
           scope: () => ({
+            Message,
             two
           })
         });"
@@ -1537,7 +1538,7 @@ describe('htmlbars-inline-precompile', function () {
 
   describe('scope', function () {
     it('correctly handles scope function (non-block arrow function)', function () {
-      let source = 'hello';
+      let source = '<foo /><bar/>';
       let spy = sinon.spy(compiler, 'precompile');
 
       transform(
@@ -1547,7 +1548,7 @@ describe('htmlbars-inline-precompile', function () {
     });
 
     it('correctly handles scope function (block arrow function)', function () {
-      let source = 'hello';
+      let source = '<foo /><bar/>';
       let spy = sinon.spy(compiler, 'precompile');
 
       transform(
@@ -1558,7 +1559,7 @@ describe('htmlbars-inline-precompile', function () {
     });
 
     it('correctly handles scope function (normal function)', function () {
-      let source = 'hello';
+      let source = '<foo /><bar/>';
       let spy = sinon.spy(compiler, 'precompile');
 
       transform(
@@ -1569,7 +1570,7 @@ describe('htmlbars-inline-precompile', function () {
     });
 
     it('correctly handles scope function (object method)', function () {
-      let source = 'hello';
+      let source = '<foo /><bar/>';
       let spy = sinon.spy(compiler, 'precompile');
 
       transform(
@@ -1579,7 +1580,7 @@ describe('htmlbars-inline-precompile', function () {
     });
 
     it('correctly handles scope function with coverage', function () {
-      let source = 'hello';
+      let source = '<foo /><bar/>';
       let spy = sinon.spy(compiler, 'precompile');
 
       transform(
@@ -1674,7 +1675,7 @@ describe('htmlbars-inline-precompile', function () {
     // whether you've (for example) capitalized your variable identifier.
     //
     // needs https://github.com/glimmerjs/glimmer-vm/pull/1421
-    it.skip('shadows html elements with locals', function () {
+    it('shadows html elements with locals', function () {
       plugins = [
         [
           HTMLBarsInlinePrecompile,
@@ -1693,11 +1694,16 @@ describe('htmlbars-inline-precompile', function () {
       );
 
       expect(transformed).toMatchInlineSnapshot(`
-        import templateOnly from "@ember/component/template-only";
+        "import { precompileTemplate } from "@ember/template-compilation";
         import { setComponentTemplate } from "@ember/component";
-        import { precompileTemplate } from "@ember/template-compilation";
+        import templateOnly from "@ember/component/template-only";
         let div = 1;
-        export default setComponentTemplate(precompileTemplate('<div></div>', { scope: () => ({ div }), strictMode: true }), templateOnly());
+        export default setComponentTemplate(precompileTemplate("<div></div>", {
+          scope: () => ({
+            div
+          }),
+          strictMode: true
+        }), templateOnly());"
       `);
     });
 
@@ -1734,7 +1740,7 @@ describe('htmlbars-inline-precompile', function () {
     });
 
     // needs https://github.com/glimmerjs/glimmer-vm/pull/1421
-    it.skip('leaves ember keywords alone when no local is defined', function () {
+    it('leaves ember keywords alone when no local is defined', function () {
       plugins = [
         [
           HTMLBarsInlinePrecompile,
@@ -1752,10 +1758,12 @@ describe('htmlbars-inline-precompile', function () {
       );
 
       expect(transformed).toMatchInlineSnapshot(`
-        import templateOnly from "@ember/component/template-only";
+        "import { precompileTemplate } from "@ember/template-compilation";
         import { setComponentTemplate } from "@ember/component";
-        import { precompileTemplate } from "@ember/template-compilation";
-        export default setComponentTemplate(precompileTemplate('{{hasBlock "thing"}}', { strictMode: true }), templateOnly());
+        import templateOnly from "@ember/component/template-only";
+        export default setComponentTemplate(precompileTemplate("{{hasBlock \\"thing\\"}}", {
+          strictMode: true
+        }), templateOnly());"
       `);
     });