Constructive Code Review #261
Replies: 1 comment
-
CTParser.jl — Design Principles Review (Part 2)
1. Single Responsibility Principle (SRP)1.1
|
| Responsibility | Fields |
|---|---|
| Parser state (what has been declared) | t, t0, tf, x, u, v, dim_*, aliases, is_autonomous, criterion |
| Parse-progress bookkeeping | lnum, line, is_global_dyn, is_coord_dyn, dyn_coords |
| ExaModels code-generation buffers | dt, dyn_con, l_v, u_v, box_v, l_x, u_x, box_x, l_u, u_u, box_u |
The third group is never read by the :fun backend — it exists solely to accumulate ExaModels variable names and box-constraint expressions. Yet every parse! call creates and carries these fields regardless of the active backend.
Proposal — split into three focused structs:
# 1. Shared parser state (both backends)
@with_kw mutable struct ParseContext
v::Symbol = __symgen(:unset)
t::Union{Symbol,Nothing} = nothing
t0::Union{Real,Symbol,Expr,Nothing} = nothing
tf::Union{Real,Symbol,Expr,Nothing} = nothing
x::Union{Symbol,Nothing} = nothing
u::Union{Symbol,Nothing} = nothing
dim_v::Union{Integer,Symbol,Expr,Nothing} = nothing
dim_x::Union{Integer,Symbol,Expr,Nothing} = nothing
dim_u::Union{Integer,Symbol,Expr,Nothing} = nothing
is_autonomous::Bool = true
criterion::Union{Symbol,Nothing} = nothing
aliases::OrderedDict = __init_aliases()
end
# 2. Parse-progress bookkeeping (both backends)
@with_kw mutable struct ParseProgress
lnum::Int = 0
line::String = ""
is_global_dyn::Bool = false
is_coord_dyn::Bool = false
dyn_coords::Vector{Int} = Int[]
end
# 3. ExaModels code buffers (:exa backend only)
@with_kw mutable struct ExaCodeBuffers
dt::Symbol = __symgen(:dt)
dyn_con::Union{Symbol,Nothing} = nothing
l_v::Symbol = __symgen(:l_v)
u_v::Symbol = __symgen(:u_v)
box_v::Expr = :(LineNumberNode(0, "box constraints: variable"))
# ... etc.
endExaCodeBuffers is only instantiated when the :exa backend is active, and only passed to p_*_exa! functions. The :fun handler signatures never see it.
1.2 parse! handles syntax recognition, dispatch, and error formatting
📋 Applying Architecture Rule: Single Responsibility — functions longer than 50 lines, multiple if-else branches handling different concerns
parse! currently does three things in one 120-line @match block:
- Alias substitution on the incoming expression
- Pattern recognition (what kind of declaration is this?)
- Dispatch to the backend-specific handler
Proposal — separate these three steps:
function parse!(ctx, progress, p_ocp, e; log=false, backend=:fun)
e = _apply_aliases(ctx.aliases, e) # step 1: pure substitution
kind, args = _recognize(e, progress) # step 2: pattern → tagged tuple
return _dispatch(kind, args, ctx, progress, p_ocp; log, backend) # step 3
end_recognize is a pure function (no side effects, no mutation) that returns a tagged tuple like (:constraint, e1, e2, e3, label). This is independently testable without any backend machinery.
1.3 def_fun orchestrates too much
📋 Applying Architecture Rule: Single Responsibility — function names with implicit "and"
def_fun currently: parses the expression for :fun, conditionally parses it again for :exa, wraps both results, and assembles the final Expr. That is at least three responsibilities.
Proposal:
function def_fun(e; log=false)
fun_code = _gen_fun_code(e; log)
exa_block = is_active_backend(:exa) ? _gen_exa_code(e; log) : nothing
return _assemble_def(fun_code, exa_block)
endEach helper has one job and can be tested in isolation.
2. Open/Closed Principle (OCP)
2.1 Adding a new backend requires modifying three constants
📋 Applying Architecture Rule: Open/Closed — avoid hard-coded type checks, design for extension without modification
const PARSING_BACKENDS = (:fun, :exa) # must be modified
const PARSING_DIR = OrderedDict(...) # must be modified
const ACTIVE_PARSING_BACKENDS = OrderedDict(...) # must be modifiedAdding a hypothetical :jump backend (JuMP.jl) requires touching all three. The activate_backend / deactivate_backend functions also contain hardcoded guards:
backend == :fun && throw("backend :fun is always active")This hard-codes the asymmetry between :fun and all other backends.
Proposal — a registration API:
abstract type AbstractParsingBackend end
struct BackendEntry
handlers::OrderedDict{Symbol,Function}
always_active::Bool
end
const BACKEND_REGISTRY = OrderedDict{Symbol,BackendEntry}()
function register_backend!(name::Symbol, handlers; always_active=false)
BACKEND_REGISTRY[name] = BackendEntry(handlers, always_active)
end
# At load time:
register_backend!(:fun, PARSING_FUN; always_active=true)
register_backend!(:exa, PARSING_EXA)A downstream package can now add a :jump backend by calling register_backend!(:jump, PARSING_JUMP) without touching CTParser's source. This is the Open/Closed principle in practice.
2.2 is_active_backend check in def_fun is a hard-coded branch
📋 Applying Architecture Rule: Open/Closed — avoid isa/typeof checks
if is_active_backend(:exa)
build_exa = def_exa(e; log=log)
...
endEvery new backend would add another if is_active_backend(:new_backend) branch. With the registry above, this becomes:
for (name, entry) in BACKEND_REGISTRY
entry.always_active && continue # :fun already handled
is_active_backend(name) && (code = concat(code, _gen_backend_code(name, e; log)))
end3. Liskov Substitution Principle (LSP)
3.1 The two backends are not contract-compatible
📋 Applying Architecture Rule: Liskov Substitution — subtypes must honor parent contracts
The :fun backend for p_dynamics_coord! accepts any index type (integer or range). The :exa backend silently rejects non-integer indices with a throw. This means the two backends do not honour the same contract for the same DSL syntax:
# Works with :fun, throws with :exa
∂(x[1:2])(t) == [v(t), u(t)]There is no LSP violation per se (the backends are not subtypes of a common abstract type), but the inconsistency is the symptom of a missing shared contract. If backends were required to implement a declared interface, this gap would be caught at registration time rather than at the user's @def.
Proposal. Define a trait or a check_capabilities function that each backend must implement, returning the set of syntax forms it supports. parse! can then emit a clear error — "this syntax is not supported by the :exa backend" — instead of a cryptic throw deep in p_dynamics_coord_exa!.
4. Dependency Inversion Principle (DIP)
4.1 Backends are referenced by Symbol, not by abstraction
📋 Applying Architecture Rule: Dependency Inversion — depend on abstractions, not concrete implementations
const PREFIX_FUN = Ref(:CTModels)
const PREFIX_EXA = Ref(:ExaModels)Generated code does things like:
:($pref.variable!($p_ocp, $q, $vv))
# expands to: CTModels.variable!(p_ocp, q, vv)The dependency on CTModels and ExaModels is inverted through string interpolation rather than through a proper abstraction. There is no way to statically verify that CTModels actually exports variable!, that its signature matches, or that the module is in scope at expansion time.
Proposal. Define a small module-level protocol as a @doc-annotated checklist (since Julia does not have interfaces in the formal sense):
# In CTParser:
"""
Backend module protocol. A module `M` qualifies as a CTParser functional
backend if it exports:
- M.PreModel() → an OCP builder object
- M.variable!(ocp, q, name) → nothing
- M.state!(ocp, n, name) → nothing
- M.control!(ocp, m, name) → nothing
- M.time!(ocp; t0, tf, ...) → nothing
- M.dynamics!(ocp, f) → nothing
- M.constraint!(ocp, kind; ...) → nothing
- M.objective!(ocp, type; ...) → nothing
- M.build(ocp; ...) → Model
"""Pair this with a validate_backend_protocol(mod::Module) function that checks the presence of each required name via isdefined, callable at package load time or in tests. This makes the implicit contract explicit and detectable.
5. DRY — Don't Repeat Yourself
5.1 Euler/midpoint/trapeze branches are copy-pasted across four functions
📋 Applying Architecture Rule: DRY — every piece of knowledge should have a single representation
The scheme-selection if scheme == :euler ... elseif scheme == :midpoint ... block appears identically (modulo variable names) in:
p_dynamics_exa!p_dynamics_coord_exa!p_lagrange_exa!
Each repetition means a bug fix or a new scheme (e.g., :runge_kutta4) must be applied in three places.
Proposal. Extract the scheme logic into a single function that takes the pre-computed ej1, ej2, ej12, dxj expressions and the scheme name:
function _exa_scheme_expr(scheme_sym, dt, ej1, ej2, ej12, lhs, j1, grid_size)
quote
if $scheme_sym == :euler
$pref.constraint($p_ocp, $lhs - $dt * $ej1 for $j1 in 0:(grid_size-1))
elseif $scheme_sym == :midpoint
$pref.constraint($p_ocp, $lhs - $dt * $ej12 for $j1 in 0:(grid_size-1))
elseif $scheme_sym ∈ (:trapeze, :trapezoidal)
$pref.constraint($p_ocp, $lhs - $dt * ($ej1 + $ej2)/2 for $j1 in 0:(grid_size-1))
else
throw("unknown scheme: $scheme_sym")
end
end
endAdding :runge_kutta4 then requires touching exactly one function.
5.2 Box-constraint accumulation is repeated for v, x, and u
📋 Applying Architecture Rule: DRY
In p_variable_exa!, p_state_exa!, and p_control_exa!, the pattern
code_box = :($(p.l_x) = -Inf * ones($n); $(p.u_x) = Inf * ones($n))
p.box_x = concat(p.box_x, code_box)is repeated three times with different field names. Similarly in p_constraint_exa! for :state_range, :control_range, :variable_range.
Proposal. A single helper:
function _init_box_code!(buffers, which::Symbol, dim)
l = getfield(buffers, Symbol(:l_, which))
u = getfield(buffers, Symbol(:u_, which))
code = :($l = -Inf * ones($dim); $u = Inf * ones($dim))
old = getfield(buffers, Symbol(:box_, which))
setfield!(buffers, Symbol(:box_, which), concat(old, code))
endcalled as _init_box_code!(exa_buffers, :x, n).
6. KISS — Keep It Simple
6.1 has uses a sentinel value to simulate early-exit
📋 Applying Architecture Rule: KISS — prefer simple solutions
has(e, e1) = begin
foo(e1) = (h, args...) -> begin
ee = Expr(h, args...)
if :yes ∈ args # ← sentinel check on every node
:yes
else
ee == e1 ? :yes : ee
end
end
expr_it(e, foo(e1), x -> x == e1 ? :yes : x) == :yes
endThe sentinel :yes bubbles up through the entire tree even after the match is found — there is no early exit. The approach also conflates the AST node type (Expr or Symbol) with a control-flow signal (:yes).
Proposal. Use a thrown exception for early exit — a well-known Julia pattern for tree search:
struct _Found end
function has(e, e1)
try
_has_walk(e, e1)
return false
catch ::_Found
return true
end
end
_has_walk(e, e1) = (e == e1 && throw(_Found()); nothing)
_has_walk(e::Expr, e1) = (e == e1 && throw(_Found()); foreach(a -> _has_walk(a, e1), e.args))This exits immediately on the first match, is type-stable, and does not require expr_it at all for this use case.
6.2 __init_aliases pre-populates 20 entries that are almost never used
📋 Applying Architecture Rule: KISS / YAGNI
for i in 1:max_dim # max_dim = 20
al[Symbol(:R, CTBase.ctupperscripts(i))] = :(R^$i)
endThis creates aliases R¹ through R²⁰ eagerly at every @def expansion, regardless of whether the user ever writes R³ in their problem. The max_dim=20 keyword argument is also never called with a non-default value anywhere in the codebase.
Proposal. Either make the alias resolution lazy (look up Rⁿ on demand), or simply hard-code the most commonly used values (up to R⁶, say) and remove the max_dim parameter. Applying YAGNI: if nobody has needed R¹⁷ yet, don't generate it.
7. Julia-Specific Pattern: Multiple Dispatch for Backend Dispatch
7.1 Dictionary-based dispatch misses Julia's native extensibility
📋 Applying Architecture Rule: Multiple Dispatch for extensibility and clarity
Backend dispatch currently goes through a dictionary lookup:
function parsing(s, backend)
return PARSING_DIR[backend][s]
end
# Usage:
parsing(:constraint, backend)(p, p_ocp, ...)This is extensible (add entries to the dict) but bypasses Julia's method dispatch entirely, which means:
- No
MethodErrorif a handler is missing — you get aKeyErrorat runtime instead - No specialisation by the compiler
- No possibility to use
@whichormethods()to introspect what will be called
Proposal. Encode the backend as a type and dispatch natively:
struct FunBackend end
struct ExaBackend end
# Each handler is a method, not a dict entry:
_emit_constraint!(::FunBackend, p, p_ocp, e1, e2, e3, c_type, label) = ...
_emit_constraint!(::ExaBackend, p, p_ocp, e1, e2, e3, c_type, label) = ...A new backend adds methods; it does not touch any existing dict. @which _emit_constraint!(ExaBackend(), ...) works for debugging. Missing methods produce a MethodError with a useful message.
Summary
| Principle | Finding | Impact |
|---|---|---|
| SRP | ParsingInfo is a God Object (3 responsibilities) |
High |
| SRP | parse! mixes recognition, substitution, dispatch |
Medium |
| OCP | New backend requires editing 3 constants | High |
| OCP | is_active_backend check will grow with each new backend |
Medium |
| LSP | :fun/:exa expose different syntax contracts silently |
Medium |
| DIP | Backends referenced by Symbol, no static contract |
High |
| DRY | Scheme branches copy-pasted across 3 functions | High |
| DRY | Box-constraint init repeated for v, x, u |
Low |
| KISS | has sentinel trick — no early exit, mixes types |
Low |
| KISS/YAGNI | 20 eagerly-generated Rⁿ aliases |
Low |
| Multiple Dispatch | Dict-based backend dispatch bypasses Julia's native mechanism | Medium |
The highest-leverage refactoring is the combination of P1 from the first comment (make :exa opt-in) + the registration API above (OCP §2.1) + multiple dispatch for handlers (§7.1): together they decouple the two backends completely, make CTParser extensible to new targets, and align the package with idiomatic Julia.
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
CTParser.jl — Constructive Code Review
Table of Contents
1. Overall Assessment
CTParser.jl is an ambitious and technically sophisticated package. Handling a mathematical DSL that maps seamlessly to both a functional representation (CTModels) and a directly-discretised NLP (ExaModels) in a single macro pass is genuinely difficult, and the general approach — one-pass
@match-driven AST transformation producing JuliaExprtrees — is sound.That said, the current implementation accumulates significant incidental complexity: the two backends are deeply entangled, the
ParsingInfostruct mixes parsing state with code generation artefacts, and many helper functions share confusingly similar names. The proposals below aim to reduce this complexity without changing the external DSL contract.2. Architecture & Design
2.1 Symbol-based module references are fragile
The entire code-generation layer uses
Symbols like:CTModelsor:ExaModelsthat are interpolated intoExprtrees. At expansion time these resolve to whatever is in scope, making the package sensitive to the import context and impossible to verify statically.Proposal. Introduce a thin abstract interface:
or, more pragmatically, keep the
Ref{Symbol}but add a validation step at backend registration time that checks the symbol resolves to a loaded module, and document clearly that the macro must be expanded in a scope whereCTModels(resp.ExaModels) is imported.2.2 Both backends always generate code
In
def_fun:The
:exacode is generated unconditionally (unless the user callsdeactivate_backend(:exa)). This means every@definvocation runs two full parsing passes and embeds a closure containing all the discretised ExaModels constraints into the compiled output, even when ExaModels is never used. This is wasteful in packages that only need the functional backend.Proposal. Make
:exaopt-in, not opt-out. Ship a separate@def_discretemacro (or a keyword argument@def ... backends=[:fun]) so the default path stays lean. Thedeactivate_backendAPI becomes an escape hatch rather than the normal workflow.2.3 Asymmetric backend activation
The fact that
:funcannot be deactivated is a hardcoded assumption. If a user ever wants only the discretised form, they cannot get it. This also means theACTIVE_PARSING_BACKENDSdict has mixed semantics (:funis alwaystrue, others are toggleable).Proposal. Either remove
:funfromACTIVE_PARSING_BACKENDSentirely and document that it is always the primary backend, or make all backends truly symmetric.2.4 The
getterclosure is generated insidedef_exabut used indef_fundef_exareturns a function that closes over symbolic variable names (p.x,p.u,p.dyn_con, …). These aregensym-ed symbols whose values are only valid within thedef_exa-generated closure scope. This works, but the boundary between what is "parsing-time" information and what is "runtime" information is invisible to a reader ofdef_fun.Proposal. Extract the getter-building logic into a dedicated
_build_getter(p)function with clear documentation of whatpfields it consumes, and call it explicitly fromdef_exa.3. defaults.jl
3.1
$(TYPEDSIGNATURES)on zero-argument functionsEvery function in this file uses the
$(TYPEDSIGNATURES)docstring extension, which renders as an empty signature(). The extension adds no value here and clutters the rendered documentation.Proposal. Drop
$(TYPEDSIGNATURES)from zero-argument functions, or switch to a plain docstring.3.2 Ordering mismatch in
__default_init_exaThe comment says
(v, x, u), but inp_state_exa!the tuple is accessed asinit[2]and inp_control_exa!asinit[3], whilep_variable_exa!usesinit[1]. The indexing is therefore(variable, state, control)— not(v, x, u)as the comment states (same symbols, different conventional ordering).Proposal. Fix the comment to
# (variable, state, control)and add an assertion or@assertin the consuming functions to catch future misalignments.3.3 Magic number
250for grid sizeThe default grid size of
250is not documented anywhere in terms of accuracy/performance trade-off.Proposal. Add a note in the docstring explaining that
250is a pragmatic default that gives roughly first-order accuracy of ~0.4% on smooth problems with the midpoint scheme, and that users should increase it for high-accuracy needs.4. utils.jl
4.1 Confusing naming of the
subs*familyThe package has four substitution helpers with nearly identical names:
subse1bye2inesubs2x[i]→y[i,j]andx[rg]→ comprehensionsubs2m(y[i,j]+y[i,j+1])/2subs3x[rg]→y[i,j]regardless ofrg(loop variable)The numbering conveys nothing about semantics. A reader encountering
subs2minp_lagrange_exa!has no intuition about what it does.Proposal. Rename to convey intent:
4.2
replace_callacceptsNothingsymbols silentlyAllowing
Nothingin the symbol vector means callers can pass[p.x, p.u]even when the control has not yet been declared (p.u === nothing). TheNothingis quietly skipped. This is pragmatic but masks a potential semantic error: the constraint/dynamics expression references a control that the parser has not yet seen.Proposal. Add a pre-call assertion (or at minimum a logging warning) when
nothingappears in the symbol vector, and document the contract explicitly. The checkisnothing(p.u) && return __throw(...)pattern already exists inp_lagrange!— make it consistent everywhere.4.3
expr_itbottom-up vs. top-down behaviorexpr_itis a bottom-up (post-order) tree walker: it recursively processes children first, then applies_Exprto the reconstructed node. This is the right choice for substitution, but forreplace_call— which needs to detect the patterneee(tt)after children have already been substituted — it can produce unexpected results when substitutions are nested (e.g.,((x^2)(t0) + u[1])(t)in the docstring example shows this subtlety).Proposal. Document the traversal order explicitly in the docstring of
expr_it("post-order / bottom-up") so callers understand why nested calls work the way they do.4.4
hasis O(n) and called repeatedlyIn
p_constraint!,p_dynamics!, etc.,has(e, p.t)is called on potentially large expressions at every parsed line.hasdoes a full tree walk returning:yesas a sentinel value (mixing data and control flow).Proposal. The sentinel trick is clever but unusual — consider instead throwing a custom exception (
:found) and catching it for an early-exit, or returning aBooldirectly fromexpr_itvia short-circuit. For the use-cases here the performance impact is small, but the readability cost is real.5. onepass.jl — ParsingInfo
5.1 Parsing state and code accumulation are mixed in one struct
ParsingInfocurrently holds two very different kinds of data:t,t0,tf,x,u,v,dim_*,is_autonomous,criterion,aliases, …box_v,box_x,box_u,l_v,u_v,l_x,u_x,l_u,u_u,dyn_con,dyn_coords,dtFields of the second kind are only meaningful for the
:exabackend and should not be present when parsing for:fun.Proposal. Split into two structs:
Pass an
ExaAccumulatoronly top_*_exa!functions. This also makes it impossible to accidentally access ExaModels-specific state from:funhandlers.5.2
__symgensuffix collisionsgensym()in Julia returns a symbol like##123, so__symgen(:p_ocp)produces:p_ocp##123. This is fine for hygiene, but within a single@defexpansion several__symgen(:j)calls produce distinctj##nsymbols, making the generated code hard to read in macro-expansion debugging (@macroexpand).Proposal. This is a minor quality-of-life issue. Consider
__symgen(s...) = gensym(string(s...))which produces slightly cleaner names like##p_ocp#123, or use a counter-based scheme for readability during development.6. onepass.jl —
parse!and dispatch6.1
parse!is a monolithic 120-line matchThe top-level
parse!function is a single@matchblock covering all 40+ syntactic forms. While this centralises dispatch, it makes it very hard to:∂($x[$i])($t)must come before∂($x)($t))Proposal. Keep
parse!as the entry point but extract groups of arms into helper functions with documented priorities:Each helper returns
nothingif the pattern doesn't match, or the generated code otherwise.6.2 The
lnumcounter is fragileparse!incrementsp.lnumat the top and decrements it when aLineNumberNodeis encountered. This works for a flat block, but could drift if an exception is thrown mid-increment or ifparse!is called recursively (the block case at the bottom does call itself recursively, which is correct, but thep.lnum = p.lnum - 1on entry to the block path relies on the outer call having already incremented).Proposal. Track
lnumviaLineNumberNodenodes directly rather than by incrementing a counter. Store the last seenLineNumberNodeinp.last_lnnand update it whenever such a node is encountered:This makes the line number always accurate regardless of recursion depth.
7. onepass.jl — Backend implementations
7.1
p_constraint_exa!is over 120 lines with repeated patternsThe
:exaconstraint handler repeats a nearly-identical code pattern for:initial,:final,:variable_range,:state_range,:control_range. The pattern is:rgto a default range ifnothingbox_*or emit anExaModels.constraintcallProposal. Extract a helper:
7.2 Dynamics code generates all schemes but only one runs
In
p_dynamics_exa!, the generated code contains anif scheme == :euler ... elseif ... endblock that is embedded verbatim. This means the compiler sees all four scheme branches at runtime. Sinceschemeis a keyword argument to the returned closure (not a type parameter), the compiler cannot specialise on it, and all four code paths are always JIT-compiled.Proposal. Make
schemeaVal-wrapped parameter, or generate scheme-specific closures atExaModelbuild time:This is more code but avoids dead branches in the hot path.
7.3
p_dynamics_coord_exa!fallback for non-integeriis an errorThe non-integer fallback always throws. This is correct in spirit but the error message is slightly misleading — if
iis a symbolic expression (e.g., a variable range), the user may not understand why their syntax is rejected. The:funbackend supportsias a range viap_dynamics_coord_fun!, creating an undocumented discrepancy between backends.Proposal. Make the discrepancy explicit in the error message:
"dynamics coordinate index $i must be a literal integer for the :exa backend (range indices are supported only by the :fun backend)"And add a test that verifies this error is thrown with the right message.
8. initial_guess.jl
This is the cleanest file in the package. The design of
_collect_init_specswith cross-spec substitution is well-thought-out. A few smaller points:8.1
spec_subsgrows linearly and is applied naivelyEach new spec applies all previous substitutions in a linear scan. For
nspecs this is O(n²) tree walks. In practicenis small, so this is not a performance concern, but it could cause a subtlety: if spec A produces a new symbol that happens to match the pattern of an earlier (already-applied) substitution, it will be silently substituted again.Proposal. Apply substitutions in a single combined pass (build a map and walk the tree once) or document the single-pass-per-spec semantics clearly.
8.2 The
@initmacro silently ignores unknown trailing rest arguments shapeIf the user writes
@init ocp begin ... end true(bareBoolwithoutlog =),opt.headwill fail theisa Exprtest and the macro will throw aParsingErrorabout "Unsupported trailing argument". The error message should mention the correct syntax.Proposal. The error message already says "Use
log = trueorlog = false", which is good. Additionally, handle theopt isa Boolcase with a more helpful message:8.3
__gen_temporal_valueruntime error message is very verboseThe generated error string (lines 80-100 of
initial_guess.jl) constructs a multi-sentence diagnostic. While informative, it is generated via string concatenation with*in the expression tree, which means it allocates even in the non-error path (the string is built before theifcheck).Proposal. Move the string construction inside the error branch:
9. Testing & Reliability
The
onepass.jlheader lists several open TODOs:These are not minor items — they are the primary safety net for a macro-based DSL where a wrong
@matcharm silently produces bad generated code.Proposal (priority order):
p_*_fun!call, assert that the generatedExprcontains no residual occurrences ofp.x,p.u, orp.t(as mentioned in the TODO). This catches incomplete substitutions early.__throw(...)call, add a test that exercises the condition and checks the error message string (use@test_throwswith a regex on the message).:funand:exabackends and verify that the discretised solution converges to the functional one asgrid_size → ∞.10. Documentation
10.1
$(TYPEDSIGNATURES)on zero-argument functionsMentioned above (§3.1). Also affects
prefix_fun(),prefix_exa(),e_prefix()inonepass.jl— all zero-argument getters. The rendered docs will show an empty signature, which is unhelpful.10.2 Internal functions exported implicitly
Functions like
parsing,activate_backend,deactivate_backend,is_active_backend,prefix_fun!,prefix_exa!,e_prefix!are all public (no_prefix, accessible asCTParser.foo). The module'sEXPORTSdoc-marker is mentioned in the module docstring but the actual export list is not shown in the provided sources. It is unclear which functions are intentionally public API vs. internal extension points.Proposal. Add a section to the README/docs that clearly distinguishes:
@def,@initprefix_fun!,activate_backend, etc. (for package authors building on CTParser)parse!,p_*_fun!, etc. (prefix with_or document as internal)10.3 The DSL syntax itself is not documented in-package
The
@defdocstring example shows valid syntax but doesn't document the full grammar. A BNF-like grammar table in the docs would help users understand what forms are supported.11. Naming Conventions
__symgen_gensymorfresh_symsubs,subs2,subs3,subs2mp_time_fun!,p_time_exa!p_prefix stands for "parse" but this is not obviousparse_time_fun!or_emit_time_fun!__throw,__wrapBase.throw_parse_error,_wrap_with_contextPARSING_FUN,PARSING_EXA,PARSING_DIR_backend_parsers_fun, etc., or use a structParsingInfoParserStateorParseContextdyn_coordsdefined_dyn_coords12. Summary of Proposals
:exaopt-in, not opt-out (or add@def_discrete)onepass.jlParsingInfointoParserState+ExaAccumulatorhas(generated_code, p.x) == false)__throwcall siteutils.jlsubs2,subs3,subs2mwith semantic namesonepass.jllnumincrement/decrement withLineNumberNodetrackingonepass.jlp_constraint_exa!pattern into a helperonepass.jlVal{scheme}dispatch to avoid dead scheme branchesdefaults.jl__default_init_exacomment orderinginitial_guess.jldefaults.jl$(TYPEDSIGNATURES)from zero-argument functions_prefix for internal helpers;parse_*prefix for parse sub-functions@defDSLReview written against the sources provided. The package is well-conceived and the DSL is expressive; the proposals above are aimed at making the internals as clean and trustworthy as the public API already appears to be.
Beta Was this translation helpful? Give feedback.
All reactions