diff --git a/README.md b/README.md index ccc42e8..ce7b37c 100755 --- a/README.md +++ b/README.md @@ -137,7 +137,8 @@ import { init, parse } from 'es-module-lexer'; source.slice(imports[3].a, imports[3].se - 1); // For non-string dynamic import expressions: - // - n will be undefined + // - n will be undefined, except for a lone template literal: import(`./a/${x}.js`) + // reports n as a glob with each ${...} collapsed to "*" (e.g. "./a/*.js") // - a is currently -1 even if there is an import attribute // - e is currently the character before the closing ) @@ -239,6 +240,10 @@ To handle escape sequences in specifier strings, the `.n` field of imported spec For dynamic import expressions, this field will be empty if not a valid JS string. +When the entire dynamic import argument is a single template literal, `.n` is reported as a glob: each `${...}` substitution is collapsed to a single `*` (for example `` import(`./locales/${locale}.js`) `` yields `./locales/*.js`). This is a full-build-only feature; the minimal build reports `undefined`. A template concatenated with anything else, or any other expression, resolves to `undefined`. Substitutions are matched by the parser itself, so a `/` inside one is correctly disambiguated as regex or division and does not affect the glob. + +The static parts are the raw specifier source: escape sequences are not cooked, and a literal `*` in the specifier is emitted as-is, so a consumer treating `.n` as a glob has to apply its own escaping. + ### Facade Detection Facade modules that only use import / export syntax can be detected via the third return value (full build only): diff --git a/chompfile.toml b/chompfile.toml index 90705fc..fd9c443 100644 --- a/chompfile.toml +++ b/chompfile.toml @@ -137,7 +137,7 @@ run = """ ${{ EMSDK_PATH }}/emsdk activate 6.0.0 ${{ EMSDK_PATH }}/upstream/emscripten/emcc src/lexer.c -o lib/lexer.wasm -Oz -flto --no-entry -s STANDALONE_WASM=1 \ - -s EXPORTED_FUNCTIONS="['_parse','_sa','_e','_ri','_re','_it','_is','_ie','_ss','_ip','_se','_ai','_id','_es','_ee','_els','_ele','_ess','_f','_ms','_ra','_rsa','_aks','_ake','_avs','_ave','___heap_base']" \ + -s EXPORTED_FUNCTIONS="['_parse','_sa','_e','_ri','_re','_it','_is','_ie','_ss','_ip','_se','_ai','_id','_es','_ee','_els','_ele','_ess','_f','_ms','_ra','_rsa','_aks','_ake','_avs','_ave','_rt','_te','_rts','___heap_base']" \ -s SUPPORT_LONGJMP=0 -s ALLOW_MEMORY_GROWTH=1 -s ERROR_ON_UNDEFINED_SYMBOLS=0 -Wno-logical-op-parentheses -Wno-parentheses """ @@ -167,7 +167,7 @@ run = """ ${{ EMSDK_FASTCOMP_PATH }}/emsdk activate 1.40.1-fastcomp ${{ EMSDK_FASTCOMP_PATH }}/fastcomp/emscripten/emcc ./src/lexer.c -o lib/lexer.emcc.js -s WASM=0 -Oz --closure 1 \ - -s EXPORTED_FUNCTIONS="['_parse','_sa','_e','_ri','_re','_it','_is','_ie','_ss','_ip','_se','_ai','_id','_es','_ee','_els','_ele','_ess','_f','_ms','_ra','_rsa','_aks','_ake','_avs','_ave','_setSource']" \ + -s EXPORTED_FUNCTIONS="['_parse','_sa','_e','_ri','_re','_it','_is','_ie','_ss','_ip','_se','_ai','_id','_es','_ee','_els','_ele','_ess','_f','_ms','_ra','_rsa','_aks','_ake','_avs','_ave','_rt','_te','_rts','_setSource']" \ -s AGGRESSIVE_VARIABLE_ELIMINATION=1 -s GLOBAL_BASE=8 \ -s ERROR_ON_UNDEFINED_SYMBOLS=0 -s TOTAL_STACK=4997968 -s --separate-asm -Wno-logical-op-parentheses -Wno-parentheses """ @@ -187,7 +187,7 @@ run = """ ${{ EMSDK_FASTCOMP_PATH }}/emsdk activate 1.40.1-fastcomp ${{ EMSDK_FASTCOMP_PATH }}/fastcomp/emscripten/emcc ./src/lexer.c -o lib/lexer.layout.js -s WASM=0 -Oz --closure 0 \ - -s EXPORTED_FUNCTIONS="['_parse','_sa','_e','_ri','_re','_it','_is','_ie','_ss','_ip','_se','_ai','_id','_es','_ee','_els','_ele','_ess','_f','_ms','_ra','_rsa','_aks','_ake','_avs','_ave','_setSource']" \ + -s EXPORTED_FUNCTIONS="['_parse','_sa','_e','_ri','_re','_it','_is','_ie','_ss','_ip','_se','_ai','_id','_es','_ee','_els','_ele','_ess','_f','_ms','_ra','_rsa','_aks','_ake','_avs','_ave','_rt','_te','_rts','_setSource']" \ -s AGGRESSIVE_VARIABLE_ELIMINATION=1 -s GLOBAL_BASE=8 \ -s ERROR_ON_UNDEFINED_SYMBOLS=0 -s TOTAL_STACK=4997968 -Wno-logical-op-parentheses -Wno-parentheses """ diff --git a/lexer.js b/lexer.js index fae6cab..7fb33c7 100644 --- a/lexer.js +++ b/lexer.js @@ -4,6 +4,7 @@ let source, pos, end, openTokenPosStack, openClassPosStack, curDynamicImport, + dynamicImportStack, templateStackDepth, facade, lastSlashWasDivision, @@ -13,6 +14,15 @@ let source, pos, end, imports, exports, exportStatementStart, + // Maps a dynamic import to its glob-recording scratch, kept off the public + // import objects so the JS result shape stays identical to the wasm build's. + // The scratch is { ends, close, depth }: `ends` collects each top-level ${...} + // end, `close` the specifier's closing backtick, `depth` the openTokenDepth + // its top-level substitutions close at. Per-import, so a nested import(`...`) + // records against its own entry and never corrupts an enclosing one. Reuses + // the real tokenizer's regex/division disambiguation, which a standalone + // ${...} skimmer cannot do. + templateGlobs, name; function addImport (ss, s, e, d) { @@ -40,10 +50,54 @@ function readName (impt) { impt.n = readString(s, source.charCodeAt(s - 1)); } +// A dynamic import whose whole argument is a lone template literal globs its +// static skeleton, collapsing each top-level ${...} to a "*" (import(`./p/${x}.js`) +// -> "./p/*.js"). The parser records each substitution's end as it goes, reusing +// the real tokenizer's regex/division disambiguation, so this only splices "*" +// per span. Mirrors dropUncommittedGlob + decodeTemplate in src/lexer.c / .ts. +function finalizeDynamicTemplate (impt) { + // Only a backtick-led argument can be a glob, so gate on it before the WeakMap + // lookup: import(x)/import(fn())/import(a + b) close here too and never recorded + // one. The glob is committed only when the specifier template's closing backtick + // was the last token of the argument (glob.close === lastTokenPos), so a + // concatenation (`a${x}` + b) or a trailing operator yields undefined. Anchoring + // on lastTokenPos, not impt.e, keeps trailing whitespace/comments from dropping + // the glob and matches the wasm/asm builds. + if (source.charCodeAt(impt.s) !== 96/*`*/) + return; + const glob = templateGlobs.get(impt); + if (glob === undefined || glob.close !== lastTokenPos) + return; + const spans = glob.ends; + const e = impt.e; + let out = '', chunkStart = impt.s + 1, index = impt.s + 1, spanIndex = 0, spanEnd = spans[0]; + // `e` bounds the walk defensively; the parser guarantees an unescaped closing + // backtick within it for a committed glob. + while (index < e) { + const ch = source.charCodeAt(index); + if (ch === 96/*`*/) + break; + if (ch === 92/*\*/) { + index += 2; + continue; + } + if (ch === 36/*$*/ && source.charCodeAt(index + 1) === 123/*{*/ && index + 2 <= spanEnd) { + out += source.slice(chunkStart, index) + '*'; + index = chunkStart = spanEnd; + spanEnd = ++spanIndex < spans.length ? spans[spanIndex] : -1; + continue; + } + index++; + } + impt.n = out + source.slice(chunkStart, index); +} + // Note: parsing is based on the _assumption_ that the source is already valid export function parse (_source, _name) { openTokenDepth = 0; curDynamicImport = null; + dynamicImportStack = []; + templateGlobs = new WeakMap(); templateDepth = -1; lastTokenPos = -1; lastSlashWasDivision = false; @@ -137,10 +191,12 @@ export function parse (_source, _name) { syntaxError(); openTokenDepth--; if (curDynamicImport && curDynamicImport.d === openTokenPosStack[openTokenDepth]) { - if (curDynamicImport.e === 0) + if (curDynamicImport.e === 0) { curDynamicImport.e = pos; + finalizeDynamicTemplate(curDynamicImport); + } curDynamicImport.se = pos; - curDynamicImport = null; + curDynamicImport = dynamicImportStack.pop(); } break; case 91/*[*/: @@ -154,6 +210,7 @@ export function parse (_source, _name) { case 44/*,*/: if (curDynamicImport && curDynamicImport.e === 0 && curDynamicImport.d === openTokenPosStack[openTokenDepth - 1]) { curDynamicImport.e = lastTokenPos + 1; + finalizeDynamicTemplate(curDynamicImport); pos++; commentWhitespace(true); curDynamicImport.a = pos; @@ -177,6 +234,15 @@ export function parse (_source, _name) { syntaxError(); if (openTokenDepth-- === templateDepth) { templateDepth = templateStack[--templateStackDepth]; + // A top-level ${...} of the recording import's specifier template just + // closed: note the position after "}" for a "*" splice. The backtick + // gate skips the WeakMap lookup unless the current import's argument + // starts with a backtick (the only shape that ever records a glob). + if (curDynamicImport && source.charCodeAt(curDynamicImport.s) === 96/*`*/) { + const glob = templateGlobs.get(curDynamicImport); + if (glob && openTokenDepth + 1 === glob.depth) + glob.ends.push(pos + 1); + } templateString(); } else { @@ -231,6 +297,11 @@ export function parse (_source, _name) { break; } case 96/*`*/: + // A backtick that is the first token of an open dynamic import's + // argument opens the specifier template: start recording its top-level + // ${...} spans so the glob can be built without re-tokenizing. + if (curDynamicImport && curDynamicImport.e === 0 && pos === curDynamicImport.s) + templateGlobs.set(curDynamicImport, { ends: [], close: -1, depth: openTokenDepth + 1 }); templateString(); break; } @@ -263,6 +334,11 @@ function tryParseImportStatement () { // The specifier start is recorded after leading whitespace/comments so it // points at the literal, matching the C lexer (src/lexer.c). const impt = addImport(startPos, pos, 0, startPos); + // Stack the enclosing dynamic import so a nested import(...) inside an + // argument restores it on close; without this the outer import's end/se + // (and template-glob recording) are lost. Popped at both finalization + // sites: the constant path just below and the main-loop ")". + dynamicImportStack.push(curDynamicImport); curDynamicImport = impt; if (ch === 39/*'*/ || ch === 34/*"*/) { stringLiteral(ch); @@ -292,6 +368,7 @@ function tryParseImportStatement () { impt.e = pos; impt.se = pos; readName(impt); + curDynamicImport = dynamicImportStack.pop(); } else { pos--; @@ -738,8 +815,20 @@ function templateString () { templateDepth = ++openTokenDepth; return; } - if (ch === 96/*`*/) + if (ch === 96/*`*/) { + // The specifier template just closed. Note its closing backtick and stop + // recording; finalizeDynamicTemplate keeps the spans only if this backtick + // is the last token of the argument. The backtick gate skips the WeakMap + // lookup for the common non-template dynamic-import argument. + if (curDynamicImport && source.charCodeAt(curDynamicImport.s) === 96/*`*/) { + const glob = templateGlobs.get(curDynamicImport); + if (glob && openTokenDepth + 1 === glob.depth) { + glob.close = pos; + glob.depth = 0; + } + } return; + } if (ch === 92/*\*/) pos++; } diff --git a/src/lexer.asm.js b/src/lexer.asm.js index f44f310..9461215 100644 --- a/src/lexer.asm.js +++ b/src/lexer.asm.js @@ -57,6 +57,8 @@ export function parse (_source, _name = '@') { let n; if (asm.ip()) n = readString(d === -1 ? s : s + 1, source.charCodeAt(d === -1 ? s - 1 : s)); + else if (!MINIMAL && d !== -1 && source.charCodeAt(s) === 96/*`*/) + n = decodeTemplate(s, e); let at = null; // minimal build drops the parsed attribute list; es-module-shims reads the // assertion via source.slice(a, se - 1) instead @@ -141,6 +143,40 @@ function readString (start, quote) { return out; } +// Glob for a lone interpolated-template specifier starting at `s`. The parser +// commits a ${...} span list only for that shape (see lexer.c), so a first rt() +// of false means "not a glob" (a concatenation such as `a${x}` + b, or a nested +// template) and yields undefined. Walking from the opening backtick, each +// top-level ${...} becomes a single "*" and is skipped via its recorded end; +// static runs are copied raw so the three ports agree byte-for-byte. The walk +// ends at the specifier's unescaped closing backtick. Kept identical to the +// wasm build's decodeTemplate (src/lexer.ts). +function decodeTemplate (s, e) { + asm.rts(); + if (!asm.rt()) + return; + let out = '', chunkStart = s + 1, index = s + 1, spanEnd = asm.te(); + // `e` bounds the walk defensively; the parser guarantees an unescaped closing + // backtick within it for a committed glob. + while (index < e) { + const ch = source.charCodeAt(index); + if (ch === 96/*`*/) + break; + if (ch === 92/*\*/) { + index += 2; + continue; + } + if (ch === 36/*$*/ && source.charCodeAt(index + 1) === 123/*{*/ && index + 2 <= spanEnd) { + out += source.slice(chunkStart, index) + '*'; + index = chunkStart = spanEnd; + spanEnd = asm.rt() ? asm.te() : -1; + continue; + } + index++; + } + return out + source.slice(chunkStart, index); +} + // Used to read escaped characters function readEscapedChar () { diff --git a/src/lexer.c b/src/lexer.c index 8443414..a86bb29 100755 --- a/src/lexer.c +++ b/src/lexer.c @@ -70,6 +70,19 @@ static inline __attribute__((always_inline)) bool handleSlash () { return false; } +// At dynamic-import finalization: keep the recorded ${...} spans only when the +// specifier template's closing backtick was the last token of the argument, so +// its non-empty list means "lone template glob". A concatenation (`a${x}` + b) +// or a trailing operator recorded spans for its first template but does not end +// on that backtick, so its list is dropped and the decoders report undefined. +// No-op in the minimal build, which never records or reads a glob. +static inline __attribute__((always_inline)) void dropUncommittedGlob (Import* import) { +#ifndef LEXER_MIN + if (import->template_close != import->end - 1) + import->template_spans = NULL; +#endif +} + // Consume one token at the current ch/pos, updating the global tokenizer state. // The single source of tokenization: the main loop and skipExpression both call // it, so the regex/keyword/import rules never diverge. Comments do not advance @@ -108,6 +121,7 @@ static inline __attribute__((always_inline)) bool consumeToken (char16_t ch) { Import* cur_dynamic_import = dynamicImportStack[dynamicImportStackDepth - 1]; if (cur_dynamic_import->end == 0) { cur_dynamic_import->end = lastTokenPos + 1; + dropUncommittedGlob(cur_dynamic_import); pos++; ch = commentWhitespace(true); cur_dynamic_import->attr_index = pos; @@ -120,8 +134,10 @@ static inline __attribute__((always_inline)) bool consumeToken (char16_t ch) { openTokenDepth--; if (dynamicImportStackDepth > 0 && openTokenStack[openTokenDepth].token == ImportParen) { Import* cur_dynamic_import = dynamicImportStack[dynamicImportStackDepth - 1]; - if (cur_dynamic_import->end == 0) + if (cur_dynamic_import->end == 0) { cur_dynamic_import->end = lastTokenPos + 1; + dropUncommittedGlob(cur_dynamic_import); + } cur_dynamic_import->statement_end = pos + 1; dynamicImportStackDepth--; } @@ -145,8 +161,29 @@ static inline __attribute__((always_inline)) bool consumeToken (char16_t ch) { break; case '}': if (openTokenDepth == 0) return syntaxError(), false; - if (openTokenStack[--openTokenDepth].token == TemplateBrace) + if (openTokenStack[--openTokenDepth].token == TemplateBrace) { +#ifndef LEXER_MIN + // A top-level ${...} of an open dynamic import's specifier template just + // closed: record the position after "}" so the decoders splice a "*". + // The top import is the innermost, so a nested import's substitutions + // record against the nested entry, never this one. + if (dynamicImportStackDepth > 0) { + Import* cur_dynamic_import = dynamicImportStack[dynamicImportStackDepth - 1]; + if (cur_dynamic_import->specifier_template_depth == openTokenDepth) { + TemplateSpan* span = (TemplateSpan*)(analysis_head); + analysis_head = analysis_head + sizeof(TemplateSpan); + span->end = pos + 1; + span->next = NULL; + if (cur_dynamic_import->template_span_tail == NULL) + cur_dynamic_import->template_spans = span; + else + cur_dynamic_import->template_span_tail->next = span; + cur_dynamic_import->template_span_tail = span; + } + } +#endif templateString(); + } break; case '\'': case '"': @@ -156,6 +193,18 @@ static inline __attribute__((always_inline)) bool consumeToken (char16_t ch) { isComment = handleSlash(); break; case '`': +#ifndef LEXER_MIN + // A backtick that opens an active dynamic import's argument (its recorded + // specifier start, so leading whitespace/comments don't matter) opens the + // specifier template: mark this import so its top-level ${...} spans are + // recorded for globbing (see struct TemplateSpan). Per-import, so a nested + // import(`...`) in a substitution records against its own entry and never + // corrupts an enclosing import. + if (dynamicImportStackDepth > 0 && + dynamicImportStack[dynamicImportStackDepth - 1]->end == 0 && + dynamicImportStack[dynamicImportStackDepth - 1]->start == pos) + dynamicImportStack[dynamicImportStackDepth - 1]->specifier_template_depth = openTokenDepth + 1; +#endif openTokenStack[openTokenDepth].pos = lastTokenPos; openTokenStack[openTokenDepth++].token = Template; templateString(); @@ -956,6 +1005,18 @@ void templateString () { if (ch == '`') { if (openTokenStack[--openTokenDepth].token != Template) syntaxError(); +#ifndef LEXER_MIN + // The specifier template just closed. Note its closing backtick and stop + // recording (depth back to 0); finalization keeps the spans only if this + // backtick is the last token of the argument. + if (dynamicImportStackDepth > 0) { + Import* cur_dynamic_import = dynamicImportStack[dynamicImportStackDepth - 1]; + if (cur_dynamic_import->specifier_template_depth == openTokenDepth + 1) { + cur_dynamic_import->template_close = pos; + cur_dynamic_import->specifier_template_depth = 0; + } + } +#endif return; } if (ch == '\\') diff --git a/src/lexer.h b/src/lexer.h index 20ac5fe..2dc325a 100755 --- a/src/lexer.h +++ b/src/lexer.h @@ -36,6 +36,20 @@ struct Attribute { typedef struct Attribute Attribute; #endif +#ifndef LEXER_MIN +// A ${...} substitution inside a lone dynamic-import template specifier. `end` +// is the source position just after the closing "}". The decoders replace the +// whole substitution with a single "*" glob wildcard, so they only need where +// each one ends. Recorded here (in the parser, which already disambiguates +// regex from division) so the decoders never re-tokenize the body. Glob `n` is +// a full-build-only feature; es-module-shims reads specifiers via source.slice. +struct TemplateSpan { + const char16_t* end; + struct TemplateSpan* next; +}; +typedef struct TemplateSpan TemplateSpan; +#endif + struct Import { const char16_t* start; const char16_t* end; @@ -47,6 +61,19 @@ struct Import { enum ImportType import_ty; #ifndef LEXER_MIN struct Attribute* attributes; + // Top-level ${...} substitution end positions, in source order, iff the + // argument is a lone interpolated template literal (so a non-empty list is + // itself the "this is a glob" signal). NULL otherwise. `template_span_tail` + // and `specifier_template_depth` are recording scratch, valid only while the + // specifier template is open; kept per-import so a nested import(`...`) in a + // substitution cannot corrupt an enclosing import's list. + struct TemplateSpan* template_spans; + struct TemplateSpan* template_span_tail; + // Position of the specifier template's closing backtick, once seen. The + // argument is a lone template glob iff this is the last token before ")" or + // ",", so a concatenation (`a${x}` + b) is rejected at finalization. + const char16_t* template_close; + uint16_t specifier_template_depth; #endif struct Import* next; }; @@ -163,6 +190,10 @@ void addImport (const char16_t* statement_start, const char16_t* start, const ch import->safe = dynamic == STANDARD_IMPORT; #ifndef LEXER_MIN import->attributes = NULL; + import->template_spans = NULL; + import->template_span_tail = NULL; + import->template_close = NULL; + import->specifier_template_depth = 0; #endif import->next = NULL; #ifndef LEXER_MIN @@ -234,6 +265,38 @@ uint32_t id () { uint32_t ip () { return import_read_head->safe; } + +#ifndef LEXER_MIN +// The last span node rt() returned. template_span_started distinguishes "not +// started" from "exhausted": once exhausted rt() stays false rather than +// reloading the first span, which would make the decoder jump backward and +// loop. rts() re-arms both for the next import. +TemplateSpan* template_span_read_head; +bool template_span_started; + +// readTemplateSpan: advance to the next ${...} substitution end of the current +// import's lone template specifier. False once exhausted, and stays false. +bool rt () { + if (!template_span_started) { + template_span_started = true; + template_span_read_head = import_read_head->template_spans; + } + else if (template_span_read_head != NULL) { + template_span_read_head = template_span_read_head->next; + } + return template_span_read_head != NULL; +} +// getTemplateSpanEnd: source offset just after the current substitution's "}". +uint32_t te () { + return template_span_read_head->end - source; +} +// resetTemplateSpans +void rts () { + template_span_read_head = NULL; + template_span_started = false; +} +#endif + // getExportStart uint32_t es () { return export_read_head->start - source; diff --git a/src/lexer.ts b/src/lexer.ts index 2c6ba68..84b7017 100755 --- a/src/lexer.ts +++ b/src/lexer.ts @@ -50,6 +50,12 @@ export interface ImportSpecifier { * For dynamic import expressions, this field will be empty if not a valid JS string. * For static import expressions, this field will always be populated. * + * A dynamic import whose entire argument is a single template literal is + * reported as a glob: each `${...}` substitution is collapsed to a single + * `*` (full build only; the minimal build reports undefined). Other + * expressions (including a template concatenated with anything else) remain + * undefined. + * * @example * const [imports1, exports1] = parse(String.raw`import './\u0061\u0062.js'`); * imports1[0].n; @@ -62,6 +68,10 @@ export interface ImportSpecifier { * const [imports3, exports3] = parse(`import("./" + "ab.js")`); * imports3[0].n; * // Returns undefined + * + * const [imports4, exports4] = parse('import(`./locales/${locale}.js`)'); + * imports4[0].n; + * // Returns "./locales/*.js" */ readonly n: string | undefined; /** @@ -263,6 +273,8 @@ export function parse (source: string, name = '@'): readonly [ let n; if (wasm.ip()) n = decode(source.slice(d === -1 ? s - 1 : s, d === -1 ? e + 1 : e)); + else if (!MINIMAL && d !== -1 && source[s] === '`') + n = decodeTemplate(s, e); let at: Array<[string, string]> | null = null; // minimal build drops the parsed attribute list; es-module-shims reads the // assertion via source.slice(a, se - 1) instead @@ -302,6 +314,39 @@ export function parse (source: string, name = '@'): readonly [ return str; } + // Glob for a lone interpolated-template specifier starting at `s`. The parser + // commits a ${...} span list only for that shape (see lexer.c), so a first + // rt() of false means "not a glob" (a concatenation such as `a${x}` + b, or a + // nested template) and yields undefined. Walking from the opening backtick, + // each top-level ${...} becomes a single "*" and is skipped via its recorded + // end; static runs are copied raw so the three ports agree byte-for-byte. The + // walk ends at the specifier's unescaped closing backtick. + function decodeTemplate (s: number, e: number) { + wasm.rts(); + if (!wasm.rt()) + return; + let out = '', chunkStart = s + 1, index = s + 1, spanEnd = wasm.te(); + // `e` bounds the walk defensively; the parser guarantees an unescaped + // closing backtick within it for a committed glob. + while (index < e) { + const ch = source.charCodeAt(index); + if (ch === 96/*`*/) + break; + if (ch === 92/*\*/) { + index += 2; + continue; + } + if (ch === 36/*$*/ && source.charCodeAt(index + 1) === 123/*{*/ && index + 2 <= spanEnd) { + out += source.slice(chunkStart, index) + '*'; + index = chunkStart = spanEnd; + spanEnd = wasm.rt() ? wasm.te() : -1; + continue; + } + index++; + } + return out + source.slice(chunkStart, index); + } + return (MINIMAL ? [imports, exports] : [imports, exports, !!wasm.f(), !!wasm.ms()]) as ReturnType; } @@ -375,6 +420,12 @@ let wasm: { avs(): number; /** getAttributeValueEnd */ ave(): number; + /** readTemplateSpan */ + rt(): boolean; + /** getTemplateSpanEnd */ + te(): number; + /** resetTemplateSpans */ + rts(): void; }; const getWasmBytes = () => ( diff --git a/test/_unit.cjs b/test/_unit.cjs index ac151d8..4196799 100755 --- a/test/_unit.cjs +++ b/test/_unit.cjs @@ -416,12 +416,95 @@ suite('Lexer', () => { test(`Dynamic import no-substitution template specifier`, () => { // A no-substitution template literal is a constant string, so n is set the - // same as for the quoted forms; an interpolated template has no constant - // value, so n stays undefined. + // same as for the quoted forms. assert.strictEqual(parse('import("./x.js")')[0][0].n, './x.js'); assert.strictEqual(parse("import('./x.js')")[0][0].n, './x.js'); assert.strictEqual(parse('import(`./x.js`)')[0][0].n, './x.js'); - assert.strictEqual(parse('import(`./a${x}.js`)')[0][0].n, undefined); + }); + + test(`Dynamic import interpolated template specifier glob`, () => { + // Glob n is a full-build-only feature; the minimal build reports undefined. + if (min) { + assert.strictEqual(parse('import(`./a${x}.js`)')[0][0].n, undefined); + return; + } + // An interpolated template literal has no constant value, but its static + // skeleton is a useful glob: each ${...} collapses to a single "*". + assert.strictEqual(parse('import(`./a${x}.js`)')[0][0].n, './a*.js'); + assert.strictEqual(parse('import(`./p/${x}/${y}.js`)')[0][0].n, './p/*/*.js'); + assert.strictEqual(parse('import(`${x}`)')[0][0].n, '*'); + assert.strictEqual(parse('import(`${x}/tail`)')[0][0].n, '*/tail'); + // The substitution body may itself contain braces, strings, templates and + // comments without closing the glob early. + assert.strictEqual(parse('import(`a${ {y:1} }b`)')[0][0].n, 'a*b'); + assert.strictEqual(parse('import(`a${ `n${z}` }b`)')[0][0].n, 'a*b'); + assert.strictEqual(parse("import(`a${ obj['}'] }b`)")[0][0].n, 'a*b'); + assert.strictEqual(parse('import(`a${ x /* } */ }b`)')[0][0].n, 'a*b'); + assert.strictEqual(parse('import(`a${ x // }\n }b`)')[0][0].n, 'a*b'); + // A "/" inside the substitution is disambiguated by the parser's own + // tokenizer, so a regex literal carrying a "}" no longer closes the + // substitution early: the whole ${...} still collapses to a single "*". + assert.strictEqual(parse('import(`a${ /x}y/g }b`)')[0][0].n, 'a*b'); + assert.strictEqual(parse('import(`a${ /x/g }b`)')[0][0].n, 'a*b'); + assert.strictEqual(parse('import(`a${ b/c }d`)')[0][0].n, 'a*d'); + assert.strictEqual(parse('import(`a${ `n${ /x}/g }` }b`)')[0][0].n, 'a*b'); + // A "/" inside a string or comment is an ordinary character and keeps the + // glob. + assert.strictEqual(parse("import(`a${ x['/}'] }b`)")[0][0].n, 'a*b'); + // A glob is still reported when an import-attributes argument follows. + assert.strictEqual(parse('import(`./p/${x}.js`, { with: { type: "json" } })')[0][0].n, './p/*.js'); + // Only a lone template literal globs; a concatenation has no static + // skeleton, so n stays undefined. + assert.strictEqual(parse('import(`a` + b)')[0][0].n, undefined); + assert.strictEqual(parse('import(`a${x}` + `b${y}`)')[0][0].n, undefined); + assert.strictEqual(parse('import("a" + x)')[0][0].n, undefined); + assert.strictEqual(parse('import(x)')[0][0].n, undefined); + }); + + test(`Dynamic import interpolated template glob edge cases`, () => { + // The glob is a full-build-only feature; the minimal build never reports it. + if (min) { + assert.strictEqual(parse('import(`./a/${x}.js`)')[0][0].n, undefined); + return; + } + // A substitution is never evaluated: expressions with side effects collapse + // to "*" like any other, so parse() cannot execute source (a regression for + // the Wasm build cooking its skeleton with eval). + globalThis.__templateGlobEvalRan = false; + assert.strictEqual(parse('import( `${(globalThis.__templateGlobEvalRan = true, "x")}.js`)')[0][0].n, '*.js'); + assert.strictEqual(globalThis.__templateGlobEvalRan, false); + delete globalThis.__templateGlobEvalRan; + // Whitespace around the argument does not change the glob, and matches the + // no-whitespace form (a cross-build divergence regression). + assert.strictEqual(parse('import(`./a/${x}.js` )')[0][0].n, './a/*.js'); + assert.strictEqual(parse('import( `./a/${x}.js`)')[0][0].n, './a/*.js'); + assert.strictEqual(parse('import(\t`./a/${x}.js`\n)')[0][0].n, './a/*.js'); + // Static parts are raw source: CR/CRLF is not normalized and a literal "*" + // is emitted verbatim next to substitution wildcards. + assert.strictEqual(parse('import(`a\r\nb${x}`)')[0][0].n, 'a\r\nb*'); + assert.strictEqual(parse('import(`a\\*${x}.js`)')[0][0].n, 'a\\**.js'); + // More top-level ${...} than the parser could ever record must not loop or + // reuse a stale span (a regression for the rt() reload / decoder hang). + assert.strictEqual(parse('import(`${a}${b}${c}${d}` + x)')[0][0].n, undefined); + // A nested dynamic import inside a substitution keeps the outer glob and is + // itself detected, whether the inner import closes through the constant fast + // path (a quoted/no-substitution specifier) or the main loop's ")". + assert.strictEqual(parse('import(`a${ import("b.js") }c`)')[0][0].n, 'a*c'); + assert.strictEqual(parse('import(`a${x}${ import(`y`) }${w}`)')[0][0].n, 'a***'); + // A nested import that closes through the main loop must restore the outer + // import so its end/glob survive (a regression for the pure-JS-port losing + // e/se/n on any non-constant nested import). + const [[outerId, innerId]] = parse('import(`a${ import(x) }c`)'); + assert.strictEqual(outerId.n, 'a*c'); + assert.notStrictEqual(outerId.e, 0); + assert.notStrictEqual(outerId.se, 0); + assert.strictEqual(innerId.n, undefined); + // The same holds when the inner import's own specifier is an interpolated + // template: all three builds report the outer glob and the inner glob. + const [[outerTmpl, innerTmpl]] = parse('import(`a${ import(`b${y}`) }c`)'); + assert.strictEqual(innerTmpl.n, 'b*'); + assert.strictEqual(outerTmpl.n, 'a*c'); + assert.notStrictEqual(outerTmpl.e, 0); }); test(`Dynamic import template specifier with escapes and attributes`, () => {