Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: static variable analysis #770

Merged
merged 29 commits into from
Dec 28, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
4f0c2c4
feat: static variable analysis
jg-rp Nov 16, 2024
e7b8559
Accept any iterable from `children`, `arguments`, etc.
jg-rp Nov 17, 2024
a69f33c
Test analysis of standard tags
jg-rp Nov 18, 2024
e5163ba
Merge branch 'harttle:master' into static-analysis-alternate
jg-rp Nov 19, 2024
5705bc6
Use `TagToken.tokenizer` instead of creating a new one
jg-rp Nov 19, 2024
2081083
Test analysis of netsted tags
jg-rp Nov 19, 2024
502a80d
Group variables by their root value
jg-rp Nov 19, 2024
5a9d192
Test analysis of nested globals and locals
jg-rp Nov 19, 2024
2cb9a4f
Analyze included and rendered templates WIP
jg-rp Nov 20, 2024
bc6be99
Use existing tokenizer when constructing `Hash`
jg-rp Nov 21, 2024
7f63cef
Improve test coverage
jg-rp Nov 21, 2024
0d1393b
Analyze variables from `layout` and `block` tags
jg-rp Nov 21, 2024
a1972ab
Test analysis of Jekyll style includes
jg-rp Nov 21, 2024
730ab19
Handle variables that start with a nested variable
jg-rp Nov 21, 2024
c0a19e3
Async analysis
jg-rp Nov 22, 2024
1a79437
Test non-standard tag end to end
jg-rp Nov 23, 2024
d9f47d6
Implement convenience analysis methods on the `Liquid` class
jg-rp Nov 23, 2024
67fdbe5
More analysis convenience methods
jg-rp Nov 23, 2024
cde3b5d
Accept string or template array
jg-rp Nov 23, 2024
a3a93cc
Draft static analysis docs
jg-rp Nov 23, 2024
2bf55db
Deduplicate variables names
jg-rp Nov 23, 2024
3ff787d
Fix isolated scope global variable map
jg-rp Dec 5, 2024
5c76035
Coerce variables to strings instead of extending String
jg-rp Dec 5, 2024
9770ff3
Private map instead of extending Map
jg-rp Dec 5, 2024
ad2333e
Fix e2e test
jg-rp Dec 5, 2024
f73f0d1
Tentatively implement analysis of aliased variables
jg-rp Dec 6, 2024
e9b11f4
Fix nested variable segments array
jg-rp Dec 22, 2024
d1f7bea
Merge branch 'harttle:master' into static-analysis-alternate
jg-rp Dec 28, 2024
e241ef9
Update docs sidebar
jg-rp Dec 28, 2024
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
Prev Previous commit
Next Next commit
Analyze included and rendered templates WIP
  • Loading branch information
jg-rp committed Nov 20, 2024
commit 2cb9a4f25a6393432577f283d61a002d08bda82b
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export { Drop } from './drop'
export { Emitter } from './emitters'
export { defaultOperators, Operators, evalToken, evalQuotedToken, Expression, isFalsy, isTruthy } from './render'
export { Context, Scope } from './context'
export { Value, Hash, Template, FilterImplOptions, Tag, Filter, Output, Variable, VariableLocation, VariableSegments, Variables, analyze } from './template'
export { Value, Hash, Template, FilterImplOptions, Tag, Filter, Output, Variable, VariableLocation, VariableSegments, Variables, analyzeSync } from './template'
export { Token, TopLevelToken, TagToken, ValueToken } from './tokens'
export { TokenKind, Tokenizer, ParseStream } from './parser'
export { filters } from './filters'
Expand Down
37 changes: 26 additions & 11 deletions src/tags/include.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Template, ValueToken, TopLevelToken, Liquid, Tag, assert, evalToken, Hash, Emitter, TagToken, Context } from '..'
import { BlockMode, Scope } from '../context'
import { Parser } from '../parser'
import { Arguments } from '../template'
import { isValueToken } from '../util'
import { Arguments, PartialScope } from '../template'
import { isString, isValueToken, toValueSync } from '../util'
import { parseFilePath, renderFilePath } from './render'

export default class extends Tag {
Expand All @@ -23,7 +23,7 @@ export default class extends Tag {
} else tokenizer.p = begin
} else tokenizer.p = begin

this.hash = new Hash(tokenizer.remaining(), liquid.options.jekyllInclude || liquid.options.keyValueSeparator)
this.hash = new Hash(tokenizer, liquid.options.jekyllInclude || liquid.options.keyValueSeparator)
}
* render (ctx: Context, emitter: Emitter): Generator<unknown, void, unknown> {
const { liquid, hash, withVar } = this
Expand All @@ -43,6 +43,29 @@ export default class extends Tag {
ctx.restoreRegister(saved)
}

public children (partials: boolean): Iterable<Template> {
if (partials && isString(this['file'])) {
// TODO: async
// TODO: throw error if this.file does not exist?
return toValueSync(this.liquid._parsePartialFile(this['file'], true, this['currentFile']))
}

// XXX: We're silently ignoring dynamically named partial templates.
return []
}

public partialScope (): PartialScope | undefined {
if (isString(this['file'])) {
const names = Object.keys(this.hash.hash)

if (this.withVar) {
names.push(this['file'])
}

return { name: this['file'], isolated: false, scope: names }
}
}

public * arguments (): Arguments {
yield * Object.values(this.hash.hash).filter(isValueToken)

Expand All @@ -54,12 +77,4 @@ export default class extends Tag {
yield this.withVar
}
}

public * blockScope (): Iterable<string> {
for (const k of Object.keys(this.hash.hash)) {
yield k
}

// TODO: withVar
}
}
47 changes: 30 additions & 17 deletions src/tags/render.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { __assign } from 'tslib'
import { ForloopDrop } from '../drop'
import { isString, isValueToken, toEnumerable } from '../util'
import { isString, isValueToken, toEnumerable, toValueSync } from '../util'
import { TopLevelToken, assert, Liquid, Token, Template, evalQuotedToken, TypeGuards, Tokenizer, evalToken, Hash, Emitter, TagToken, Context, Tag } from '..'
import { Parser } from '../parser'
import { Arguments } from '../template'
import { Arguments, PartialScope } from '../template'

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

Expand Down Expand Up @@ -77,6 +77,34 @@ export default class extends Tag {
}
}

public children (partials: boolean): Iterable<Template> {
if (partials && isString(this['file'])) {
// TODO: async
// TODO: throw error if this.file does not exist?
return toValueSync(this.liquid._parsePartialFile(this['file'], true, this['currentFile']))
}

// XXX: We're silently ignoring dynamically named partial templates.
return []
}

public partialScope (): PartialScope | undefined {
if (isString(this['file'])) {
const names = Object.keys(this.hash.hash)

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

return { name: this['file'], isolated: true, scope: names }
}
}

public * arguments (): Arguments {
for (const v of Object.values(this.hash.hash)) {
if (isValueToken(v)) {
Expand All @@ -95,21 +123,6 @@ export default class extends Tag {
}
}
}

public * blockScope (): Iterable<string> {
yield * Object.keys(this.hash.hash)

if (this['for']) {
const { alias } = this['for']
if (isString(alias)) {
yield alias
} else if (isString(this.file)) {
yield this.file
}

yield 'forloop'
}
}
}

/**
Expand Down
66 changes: 46 additions & 20 deletions src/template/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,15 +103,19 @@ export interface StaticAnalysis {
/**
* Statically analyze a template and report variable usage.
*/
export function analyze (templates: Template[]): StaticAnalysis {
export function analyzeSync (templates: Template[], partials = true): StaticAnalysis {
const variables = new VariableMap()
const globals = new VariableMap()
const locals = new VariableMap()

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

function updateVariables (variable: Variable): void {
// Names of partial templates that we've already analyzed.
// TODO: do we need to take special measures for relative template names?
const seen: Set<string | undefined> = new Set()

function updateVariables (variable: Variable, scope: DummyScope): void {
variables.push(variable)

// Variables that are not in scope are assumed to be global, that is,
Expand All @@ -124,16 +128,16 @@ export function analyze (templates: Template[]): StaticAnalysis {
// recurse for nested Variables
for (const segment of variable.segments) {
if (segment instanceof Variable) {
updateVariables(segment)
updateVariables(segment, scope)
}
}
}

function visit (template: Template): void {
function visit (template: Template, scope: DummyScope, isolated = false): void {
if (template.arguments) {
for (const arg of template.arguments()) {
for (const variable of extractVariables(arg)) {
updateVariables(variable)
updateVariables(variable, scope)
}
}
}
Expand All @@ -148,28 +152,44 @@ export function analyze (templates: Template[]): StaticAnalysis {
} else {
templateScope.add(ident.content)
const [row, col] = ident.getPosition()
locals.push(new Variable([ident.content], { row, col, file: template.token.file }))
locals.push(new Variable([ident.content], { row, col, file: ident.file }))
}
}
}

if (template.blockScope) {
scope.push(new Set(template.blockScope()))
}

if (template.children) {
for (const child of template.children()) {
visit(child)
}
}
if (template.partialScope) {
const partial = template.partialScope()
if (partial === undefined || seen.has(partial.name)) return

const partialScope = partial.isolated
? new DummyScope(new Set(partial.scope))
: scope.push(new Set(partial.scope))

for (const child of template.children(partials)) {
visit(child, partialScope)
seen.add(partial.name)
}

if (template.blockScope) {
scope.pop()
partialScope.pop()
} else {
if (template.blockScope) {
scope.push(new Set(template.blockScope()))
}

for (const child of template.children(partials)) {
visit(child, scope)
}

if (template.blockScope) {
scope.pop()
}
}
}
}

for (const template of templates) {
visit(template)
visit(template, rootScope)
}

return {
Expand Down Expand Up @@ -198,8 +218,9 @@ class DummyScope {
return false
}

public push (scope: Set<string>): void {
public push (scope: Set<string>): DummyScope {
this.stack.push(scope)
return this
}

public pop (): Set<string> | undefined {
Expand Down Expand Up @@ -248,12 +269,17 @@ function * extractValueTokenVariables (token: ValueToken): Generator<Variable> {
function extractPropertyAccessVariable (token: PropertyAccessToken): Variable {
const segments: VariableSegments = []

// token is not guaranteed to have `file` set. We'll try to get it from a prop if not.
let file: string | undefined = token.file

if (isQuotedToken(token.variable) || isNumberToken(token.variable)) {
// XXX: I think this is unreachable.
segments.push(token.variable.content)
file = file || token.variable.file
}

for (const prop of token.props) {
file = file || prop.file
if (isQuotedToken(prop) || isNumberToken(prop) || isWordToken(prop)) {
segments.push(prop.content)
} else if (isPropertyAccessToken(prop)) {
Expand All @@ -265,7 +291,7 @@ function extractPropertyAccessVariable (token: PropertyAccessToken): Variable {
return new Variable(segments, {
row,
col,
file: token.file
file
})
}

Expand Down
6 changes: 4 additions & 2 deletions src/template/hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ type HashValueTokens = Record<string, Token | undefined>
*/
export class Hash {
hash: HashValueTokens = {}
constructor (markup: string, jekyllStyle?: boolean | string) {
const tokenizer = new Tokenizer(markup, {})

constructor (input: string | Tokenizer, jekyllStyle?: boolean | string) {
const tokenizer = input instanceof Tokenizer ? input : new Tokenizer(input, {})
for (const hash of tokenizer.readHashes(jekyllStyle)) {
this.hash[hash.name.content] = hash.value
}
}

* render (ctx: Context): Generator<unknown, Record<string, any>, unknown> {
const hash = {}
for (const key of Object.keys(this.hash)) {
Expand Down
4 changes: 3 additions & 1 deletion src/template/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import { Value } from './value'

export type Argument = IdentifierToken | Value | ValueToken
export type Arguments = Iterable<Argument>
export type PartialScope = { name: string, isolated: boolean, scope: Iterable<string> }

export interface Template {
token: Token;
render(ctx: Context, emitter: Emitter): any;
children?(): Iterable<Template>;
children?(partials: boolean): Iterable<Template>;
arguments?(): Arguments;
blockScope?(): Iterable<string>;
localScope?(): Iterable<string | IdentifierToken | QuotedToken>;
partialScope?(): PartialScope | undefined;
}
Loading
Loading