Skip to content

Commit

Permalink
Tentatively implement analysis of aliased variables
Browse files Browse the repository at this point in the history
  • Loading branch information
jg-rp committed Dec 6, 2024
1 parent ad2333e commit f73f0d1
Show file tree
Hide file tree
Showing 6 changed files with 177 additions and 54 deletions.
7 changes: 4 additions & 3 deletions src/tags/include.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Template, ValueToken, TopLevelToken, Liquid, Tag, assert, evalToken, Hash, Emitter, TagToken, Context } from '..'
import { BlockMode, Scope } from '../context'
import { Parser } from '../parser'
import { Arguments, PartialScope } from '../template'
import { Argument, Arguments, PartialScope } from '../template'
import { isString, isValueToken } from '../util'
import { parseFilePath, renderFilePath } from './render'

Expand Down Expand Up @@ -52,13 +52,14 @@ export default class extends Tag {

public partialScope (): PartialScope | undefined {
if (isString(this['file'])) {
let names: string[]
let names: Array<string | [string, Argument]>

if (this.liquid.options.jekyllInclude) {
names = ['include']
} else {
names = Object.keys(this.hash.hash)
if (this.withVar) {
names.push(this['file'])
names.push([this['file'], this.withVar])
}
}

Expand Down
16 changes: 8 additions & 8 deletions src/tags/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ForloopDrop } from '../drop'
import { isString, isValueToken, toEnumerable } from '../util'
import { TopLevelToken, assert, Liquid, Token, Template, evalQuotedToken, TypeGuards, Tokenizer, evalToken, Hash, Emitter, TagToken, Context, Tag } from '..'
import { Parser } from '../parser'
import { Arguments, PartialScope } from '../template'
import { Argument, Arguments, PartialScope } from '../template'

export type ParsedFileName = Template[] | Token | string | undefined

Expand Down Expand Up @@ -86,23 +86,23 @@ export default class extends Tag {

public partialScope (): PartialScope | undefined {
if (isString(this['file'])) {
const names = Object.keys(this.hash.hash)
const names: Array<string | [string, Argument]> = Object.keys(this.hash.hash)

if (this['with']) {
const { alias } = this['with']
const { value, alias } = this['with']
if (isString(alias)) {
names.push(alias)
names.push([alias, value])
} else if (isString(this.file)) {
names.push(this.file)
names.push([this.file, value])
}
}

if (this['for']) {
const { alias } = this['for']
const { value, alias } = this['for']
if (isString(alias)) {
names.push(alias)
names.push([alias, value])
} else if (isString(this.file)) {
names.push(this.file)
names.push([this.file, value])
}
}

Expand Down
10 changes: 10 additions & 0 deletions src/template/analysis.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ describe('Analysis variable', () => {
expect(`${v}`).toBe('foo[bar[1]]')
})

it('should represent bracketed segments', () => {
const v = new Variable(['foo', 'bar baz'], mockLocation)
expect(`${v}`).toBe("foo['bar baz']")
})

it('should represent bracketed root', () => {
const v = new Variable(['foo bar'], mockLocation)
expect(`${v}`).toBe("['foo bar']")
})

it('should have a segments property', () => {
const v = new Variable(['foo', 'bar'], mockLocation)
expect(v.segments).toStrictEqual(['foo', 'bar'])
Expand Down
103 changes: 81 additions & 22 deletions src/template/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,23 +56,23 @@ export class VariableMap {
this.map = new Map()
}

get (key: Variable): Variable[] {
public get (key: Variable): Variable[] {
const k = segmentsString([key.segments[0]])
if (!this.map.has(k)) {
this.map.set(k, [])
}
return this.map.get(k) as Variable[]
}

has (key: Variable): boolean {
public has (key: Variable): boolean {
return this.map.has(segmentsString([key.segments[0]]))
}

push (variable: Variable): void {
public push (variable: Variable): void {
this.get(variable).push(variable)
}

asObject (): Variables {
public asObject (): Variables {
return Object.fromEntries(this.map)
}
}
Expand Down Expand Up @@ -121,24 +121,29 @@ function * _analyze (templates: Template[], partials: boolean, sync: boolean): G
const globals = new VariableMap()
const locals = new VariableMap()

const templateScope: Set<string> = new Set()
const rootScope = new DummyScope(templateScope)
const rootScope = new DummyScope(new Set())

// Names of partial templates that we've already analyzed.
const seen: Set<string | undefined> = new Set()

function updateVariables (variable: Variable, scope: DummyScope) {
variables.push(variable)
const aliased = scope.alias(variable)

// Variables that are not in scope are assumed to be global, that is,
// provided by application developers.
const root = variable.segments[0]
if (isString(root) && !scope.has(root)) {
globals.push(variable)
if (aliased !== undefined) {
const root = aliased.segments[0]
if (isString(root) && !rootScope.has(root)) {
globals.push(aliased)
}
} else {
const root = variable.segments[0]
if (isString(root) && !scope.has(root)) {
globals.push(variable)
}
}

// Recurse for nested Variables
for (const segment of variable.segments.slice(1)) {
for (const segment of variable.segments) {
if (segment instanceof Variable) {
updateVariables(segment, scope)
}
Expand All @@ -157,6 +162,7 @@ function * _analyze (templates: Template[], partials: boolean, sync: boolean): G
if (template.localScope) {
for (const ident of template.localScope()) {
scope.add(ident.content)
scope.deleteAlias(ident.content)
const [row, col] = ident.getPosition()
locals.push(new Variable([ident.content], { row, col, file: ident.file }))
}
Expand All @@ -176,9 +182,23 @@ function * _analyze (templates: Template[], partials: boolean, sync: boolean): G

if (seen.has(partial.name)) return

const partialScopeNames: Set<string> = new Set()
const partialScope = partial.isolated
? new DummyScope(new Set(partial.scope))
: scope.push(new Set(partial.scope))
? new DummyScope(partialScopeNames)
: scope.push(partialScopeNames)

for (const name of partial.scope) {
if (isString(name)) {
partialScopeNames.add(name)
} else {
const [alias, argument] = name
partialScopeNames.add(alias)
const variables = Array.from(extractVariables(argument))
if (variables.length) {
partialScope.setAlias(alias, variables[0].segments)
}
}
}

for (const child of (yield template.children(partials, sync)) as Template[]) {
yield visit(child, partialScope)
Expand Down Expand Up @@ -229,36 +249,75 @@ export function analyzeSync (template: Template[], options: StaticAnalysisOption
return toValueSync(_analyze(template, opts.partials, true))
}

interface ScopeStackItem {
names: Set<string>;
aliases: Map<string, VariableSegments>;
}

/**
* A stack to manage scopes while traversing templates during static analysis.
*/
class DummyScope {
private stack: Array<Set<string>>
private stack: Array<ScopeStackItem>

constructor (globals: Set<string>) {
this.stack = [globals]
this.stack = [{ names: globals, aliases: new Map() }]
}

public has (key: string): boolean {
for (let i = this.stack.length - 1; i >= 0; i--) {
if (this.stack[i].has(key)) {
/** Return true if `name` is in scope. */
public has (name: string): boolean {
for (const scope of this.stack) {
if (scope.names.has(name)) {
return true
}
}
return false
}

public push (scope: Set<string>): DummyScope {
this.stack.push(scope)
this.stack.push({ names: scope, aliases: new Map() })
return this
}

public pop (): Set<string> | undefined {
return this.stack.pop()
return this.stack.pop()?.names
}

// Add a name to the template scope.
public add (name: string): void {
this.stack[0].add(name)
this.stack[0].names.add(name)
}

/** Return the variable that `variable` aliases, or `variable` if it doesn't alias anything. */
public alias (variable: Variable): Variable | undefined {
const root = variable.segments[0]
if (!isString(root)) return undefined
const alias = this.getAlias(root)
if (alias === undefined) return undefined
return new Variable([...alias, ...variable.segments.slice(1)], variable.location)
}

// TODO: `from` could be a path with multiple segments, like `include.x`.
public setAlias (from: string, to: VariableSegments): void {
this.stack[this.stack.length - 1].aliases.set(from, to)
}

public deleteAlias (name: string): void {
this.stack[this.stack.length - 1].aliases.delete(name)
}

private getAlias (name: string): VariableSegments | undefined {
for (const scope of this.stack) {
if (scope.aliases.has(name)) {
return scope.aliases.get(name)
}

// If a scope has defined `name`, then it masks aliases in parent scopes.
if (scope.names.has(name)) {
return undefined
}
}
return undefined
}
}

Expand Down
9 changes: 7 additions & 2 deletions src/template/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,13 @@ export interface PartialScope {
*/
isolated: boolean;

/** A list of names that will be in scope for the child template. */
scope: Iterable<string>;
/**
* A list of names that will be in scope for the child template.
*
* If an item is a [string, Argument] tuple, the string is considered an alias
* for the argument.
*/
scope: Iterable<string | [string, Argument]>;
}

export interface Template {
Expand Down
Loading

0 comments on commit f73f0d1

Please sign in to comment.