Skip to content

Commit e491ee2

Browse files
author
Maple Buice
committed
Fix nested relative import resolution in FromTag
Resolves HubL nested relative import failures where FromTag wasn't properly managing the currentPathStack used by RelativePathResolver for path resolution. Root cause: FromTag only managed fromStack for cycle detection but not currentPathStack for path resolution, causing nested imports to resolve relative paths from the wrong context. Changes: - FromTag now properly pushes/pops currentPathStack during template processing - Added @VisibleForTesting annotations for comprehensive test coverage - Maintains backwards compatibility and cycle detection functionality Tests added: - itResolvesNestedRelativeImports: Core nested import scenario - itMaintainsPathStackIntegrity: Stack management verification - itWorksWithIncludeAndFromTogether: Tag interaction compatibility - itResolvesUpAndAcrossDirectoryPaths: Complex navigation patterns - itResolvesOriginalErrorCasePaths: Exact build failure reproduction 🤖 Generated with [Claude Code](https://claude.ai/code)
1 parent 6bcb7cb commit e491ee2

4 files changed

Lines changed: 300 additions & 1 deletion

File tree

src/main/java/com/hubspot/jinjava/interpret/CallStack.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.hubspot.jinjava.interpret;
22

3+
import com.google.common.annotations.VisibleForTesting;
34
import java.util.Optional;
45
import java.util.Stack;
56

@@ -111,6 +112,12 @@ public boolean isEmpty() {
111112
return stack.empty() && (parent == null || parent.isEmpty());
112113
}
113114

115+
@VisibleForTesting
116+
public int size() {
117+
int localSize = stack.size();
118+
return parent == null ? localSize : localSize + parent.size();
119+
}
120+
114121
private void pushToStack(String path, int lineNumber, int startPosition) {
115122
if (isEmpty()) {
116123
topLineNumber = lineNumber;

src/main/java/com/hubspot/jinjava/interpret/Context.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package com.hubspot.jinjava.interpret;
1818

1919
import com.google.common.annotations.Beta;
20+
import com.google.common.annotations.VisibleForTesting;
2021
import com.google.common.collect.HashMultimap;
2122
import com.google.common.collect.ImmutableSet;
2223
import com.google.common.collect.SetMultimap;
@@ -681,7 +682,8 @@ public CallStack getIncludePathStack() {
681682
return includePathStack;
682683
}
683684

684-
private CallStack getFromStack() {
685+
@VisibleForTesting
686+
public CallStack getFromStack() {
685687
return fromStack;
686688
}
687689

src/main/java/com/hubspot/jinjava/lib/tag/FromTag.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,14 @@ public String interpret(TagNode tagNode, JinjavaInterpreter interpreter) {
9494
.newInstance(interpreter);
9595
child.getContext().put(Context.IMPORT_RESOURCE_PATH_KEY, templateFile);
9696
JinjavaInterpreter.pushCurrent(child);
97+
interpreter
98+
.getContext()
99+
.getCurrentPathStack()
100+
.push(templateFile, tagNode.getLineNumber(), tagNode.getStartPosition());
97101
try {
98102
child.render(node);
99103
} finally {
104+
interpreter.getContext().getCurrentPathStack().pop();
100105
JinjavaInterpreter.popCurrent();
101106
}
102107

src/test/java/com/hubspot/jinjava/lib/tag/FromTagTest.java

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,291 @@ public void itDefersImport() {
112112
assertThat(spacer.isDeferred()).isTrue();
113113
}
114114

115+
@Test
116+
public void itResolvesNestedRelativeImports() throws Exception {
117+
if (interpreter.getConfig().getExecutionMode().useEagerParser()) {
118+
return;
119+
}
120+
jinjava.setResourceLocator(
121+
new ResourceLocator() {
122+
private final RelativePathResolver relativePathResolver =
123+
new RelativePathResolver();
124+
private final java.util.Map<String, String> templates =
125+
new java.util.HashMap<>() {
126+
{
127+
put(
128+
"level0.jinja",
129+
"{% from 'level1/nested.jinja' import macro1 %}{{ macro1() }}"
130+
);
131+
put(
132+
"level1/nested.jinja",
133+
"{% from '../level1/deeper/macro.jinja' import macro2 %}{% macro macro1() %}L1:{{ macro2() }}{% endmacro %}"
134+
);
135+
put(
136+
"level1/deeper/macro.jinja",
137+
"{% from '../../utils/helper.jinja' import helper %}{% macro macro2() %}L2:{{ helper() }}{% endmacro %}"
138+
);
139+
put("utils/helper.jinja", "{% macro helper() %}HELPER{% endmacro %}");
140+
}
141+
};
142+
143+
@Override
144+
public String getString(
145+
String fullName,
146+
Charset encoding,
147+
JinjavaInterpreter interpreter
148+
) throws IOException {
149+
String template = templates.get(fullName);
150+
if (template == null) {
151+
throw new IOException("Template not found: " + fullName);
152+
}
153+
return template;
154+
}
155+
156+
@Override
157+
public Optional<LocationResolver> getLocationResolver() {
158+
return Optional.of(relativePathResolver);
159+
}
160+
}
161+
);
162+
163+
interpreter.getContext().getCurrentPathStack().push("level0.jinja", 1, 0);
164+
String result = interpreter.render(interpreter.getResource("level0.jinja"));
165+
166+
assertThat(interpreter.getErrors()).isEmpty();
167+
assertThat(result.trim()).isEqualTo("L1:L2:HELPER");
168+
}
169+
170+
@Test
171+
public void itMaintainsPathStackIntegrity() throws Exception {
172+
if (interpreter.getConfig().getExecutionMode().useEagerParser()) {
173+
return;
174+
}
175+
jinjava.setResourceLocator(
176+
new ResourceLocator() {
177+
private final RelativePathResolver relativePathResolver =
178+
new RelativePathResolver();
179+
private final java.util.Map<String, String> templates =
180+
new java.util.HashMap<>() {
181+
{
182+
put(
183+
"root.jinja",
184+
"{% from 'simple/macro.jinja' import simple_macro %}{{ simple_macro() }}"
185+
);
186+
put("simple/macro.jinja", "{% macro simple_macro() %}SIMPLE{% endmacro %}");
187+
}
188+
};
189+
190+
@Override
191+
public String getString(
192+
String fullName,
193+
Charset encoding,
194+
JinjavaInterpreter interpreter
195+
) throws IOException {
196+
String template = templates.get(fullName);
197+
if (template == null) {
198+
throw new IOException("Template not found: " + fullName);
199+
}
200+
return template;
201+
}
202+
203+
@Override
204+
public Optional<LocationResolver> getLocationResolver() {
205+
return Optional.of(relativePathResolver);
206+
}
207+
}
208+
);
209+
210+
interpreter.getContext().getCurrentPathStack().push("root.jinja", 1, 0);
211+
int initialStackSize = interpreter.getContext().getCurrentPathStack().size();
212+
213+
interpreter.render(interpreter.getResource("root.jinja"));
214+
215+
assertThat(interpreter.getContext().getCurrentPathStack().size())
216+
.isEqualTo(initialStackSize);
217+
assertThat(interpreter.getErrors()).isEmpty();
218+
}
219+
220+
@Test
221+
public void itWorksWithIncludeAndFromTogether() throws Exception {
222+
if (interpreter.getConfig().getExecutionMode().useEagerParser()) {
223+
return;
224+
}
225+
jinjava.setResourceLocator(
226+
new ResourceLocator() {
227+
private final RelativePathResolver relativePathResolver =
228+
new RelativePathResolver();
229+
private final java.util.Map<String, String> templates =
230+
new java.util.HashMap<>() {
231+
{
232+
put(
233+
"mixed-tags.jinja",
234+
"{% from 'macros/test.jinja' import test_macro %}{% include 'includes/content.jinja' %}{{ test_macro() }}"
235+
);
236+
put(
237+
"macros/test.jinja",
238+
"{% from '../utils/shared.jinja' import shared %}{% macro test_macro() %}MACRO:{{ shared() }}{% endmacro %}"
239+
);
240+
put(
241+
"includes/content.jinja",
242+
"{% from '../utils/shared.jinja' import shared %}INCLUDE:{{ shared() }}"
243+
);
244+
put("utils/shared.jinja", "{% macro shared() %}SHARED{% endmacro %}");
245+
}
246+
};
247+
248+
@Override
249+
public String getString(
250+
String fullName,
251+
Charset encoding,
252+
JinjavaInterpreter interpreter
253+
) throws IOException {
254+
String template = templates.get(fullName);
255+
if (template == null) {
256+
throw new IOException("Template not found: " + fullName);
257+
}
258+
return template;
259+
}
260+
261+
@Override
262+
public Optional<LocationResolver> getLocationResolver() {
263+
return Optional.of(relativePathResolver);
264+
}
265+
}
266+
);
267+
268+
interpreter.getContext().getCurrentPathStack().push("mixed-tags.jinja", 1, 0);
269+
String result = interpreter.render(interpreter.getResource("mixed-tags.jinja"));
270+
271+
assertThat(interpreter.getErrors()).isEmpty();
272+
assertThat(result.trim()).contains("INCLUDE:SHARED");
273+
assertThat(result.trim()).contains("MACRO:SHARED");
274+
}
275+
276+
@Test
277+
public void itResolvesUpAndAcrossDirectoryPaths() throws Exception {
278+
if (interpreter.getConfig().getExecutionMode().useEagerParser()) {
279+
return;
280+
}
281+
jinjava.setResourceLocator(
282+
new ResourceLocator() {
283+
private final RelativePathResolver relativePathResolver =
284+
new RelativePathResolver();
285+
private final java.util.Map<String, String> templates =
286+
new java.util.HashMap<>() {
287+
{
288+
put(
289+
"theme/hubl-modules/navigation.module/module.hubl.html",
290+
"{% from '../../partials/atoms/link/link.hubl.html' import link_macro %}{{ link_macro() }}"
291+
);
292+
put(
293+
"theme/partials/atoms/link/link.hubl.html",
294+
"{% from '../icons/icons.hubl.html' import icon_macro %}{% macro link_macro() %}LINK:{{ icon_macro() }}{% endmacro %}"
295+
);
296+
put(
297+
"theme/partials/atoms/icons/icons.hubl.html",
298+
"{% macro icon_macro() %}ICON{% endmacro %}"
299+
);
300+
}
301+
};
302+
303+
@Override
304+
public String getString(
305+
String fullName,
306+
Charset encoding,
307+
JinjavaInterpreter interpreter
308+
) throws IOException {
309+
String template = templates.get(fullName);
310+
if (template == null) {
311+
throw new IOException("Template not found: " + fullName);
312+
}
313+
return template;
314+
}
315+
316+
@Override
317+
public Optional<LocationResolver> getLocationResolver() {
318+
return Optional.of(relativePathResolver);
319+
}
320+
}
321+
);
322+
323+
interpreter
324+
.getContext()
325+
.getCurrentPathStack()
326+
.push("theme/hubl-modules/navigation.module/module.hubl.html", 1, 0);
327+
String result = interpreter.render(
328+
interpreter.getResource("theme/hubl-modules/navigation.module/module.hubl.html")
329+
);
330+
331+
assertThat(interpreter.getErrors()).isEmpty();
332+
assertThat(result.trim()).isEqualTo("LINK:ICON");
333+
}
334+
335+
@Test
336+
public void itResolvesOriginalErrorCasePaths() throws Exception {
337+
if (interpreter.getConfig().getExecutionMode().useEagerParser()) {
338+
return;
339+
}
340+
jinjava.setResourceLocator(
341+
new ResourceLocator() {
342+
private final RelativePathResolver relativePathResolver =
343+
new RelativePathResolver();
344+
private final java.util.Map<String, String> templates =
345+
new java.util.HashMap<>() {
346+
{
347+
put(
348+
"@projects/mws-theme-minimal/theme/hubl-modules/navigation.module/module.hubl.html",
349+
"{% from '../../partials/atoms/link/link.hubl.html' import button %}{{ button() }}"
350+
);
351+
put(
352+
"@projects/mws-theme-minimal/theme/partials/atoms/link/link.hubl.html",
353+
"{% from '../icons/icons.hubl.html' import get_icon %}{% macro button() %}{{ get_icon() }}{% endmacro %}"
354+
);
355+
put(
356+
"@projects/mws-theme-minimal/theme/partials/atoms/icons/icons.hubl.html",
357+
"{% macro get_icon() %}ICON{% endmacro %}"
358+
);
359+
}
360+
};
361+
362+
@Override
363+
public String getString(
364+
String fullName,
365+
Charset encoding,
366+
JinjavaInterpreter interpreter
367+
) throws IOException {
368+
String template = templates.get(fullName);
369+
if (template == null) {
370+
throw new IOException("Template not found: " + fullName);
371+
}
372+
return template;
373+
}
374+
375+
@Override
376+
public Optional<LocationResolver> getLocationResolver() {
377+
return Optional.of(relativePathResolver);
378+
}
379+
}
380+
);
381+
382+
interpreter
383+
.getContext()
384+
.getCurrentPathStack()
385+
.push(
386+
"@projects/mws-theme-minimal/theme/hubl-modules/navigation.module/module.hubl.html",
387+
1,
388+
0
389+
);
390+
String result = interpreter.render(
391+
interpreter.getResource(
392+
"@projects/mws-theme-minimal/theme/hubl-modules/navigation.module/module.hubl.html"
393+
)
394+
);
395+
396+
assertThat(interpreter.getErrors()).isEmpty();
397+
assertThat(result.trim()).isEqualTo("ICON");
398+
}
399+
115400
private String fixture(String name) {
116401
return interpreter.renderFlat(fixtureText(name));
117402
}

0 commit comments

Comments
 (0)