String interpolation list item text
arvanasse opened this issue · 6 comments
Consider the following:
class MyClass
attr_reader :name, :age
def initialize(name, age)
@name = name
@age = age
end
def create_doc
Caracal::Document.save('my_file.docx') do |document|
document.h1 'Example'
document.ul do
li "Name: #{name}"
li "Age: #{age}"
end
end
end
end
MyClass.new('john', 30).create_doc
#=> NameError: undefined local variable or method `name' for #<Caracal::Core::Models::ListModel:0x007fafbe38b650>
String interpolation into a list item seems like a pretty general need for anyone attempting to dynamically generate a document. As one possible alternative, I would not mind being able to pass an enumerable data
attribute to the document.ul
call and let Caracal add the list items from the enumerable (much like #table
). This could be done as one alternative to list generation with a fall back to the current, explicit method.
Hi, Andy.
Scoping is an fairly gnarly issue for a gem like Caracal, but I suspect there is an existing way around your issue.
What You Actually Asked Me About
First, a small bit of background.
The main difference between a Word document and an HTML document (for example) is that the Word document actually requires several output streams to be written and collected to form the "output". This complicates the rendering process for Caracal because it can't really just "render as it goes" like, say, Prawn can. For better or worse, Caracal was designed to model all DSL instructions first and only when all the instructions are stored does it attempt to produce the various output files and zip them into the final document. The reason this complicates things is because Caracal is fundamentally turning all your instructions into a series of nested model objects as it parses your commands, which by default rapidly destroys the idea of a single context in which the commands can be evaluated.
Having written that, there is generally a way to preserve outer context. In an effort to give programmers some degree of control over how their instructions are interpreted, Caracal's BaseModel implements an arity strategy I discovered in similar gems when I was designing Caracal--it chooses a block evaluation strategy based on the arity of the block. So, if you call a Caracal method without a block argument (i.e., arity < 1), the gem assumes all the variables in the block are local and it uses instance _eval(&block)
; if you provide a block argument, Caracal assumes the variables are not all local and uses block[self]
instead--self
here is the caller rather than the receiver.
Consider the following example, which removes Caracal from the equation:
class InnerClass
attr_reader :birth_year
def initialize(age)
@birth_year = 2017 - age
end
def format(&block)
(block.arity < 1) ? instance_eval(&block) : block[self]
end
end
class OuterClass
attr_reader :name, :age, :inner
def initialize(name, age)
@name = name
@age = age
@inner = InnerClass.new(age)
end
# This evaluates self in the context of the caller
# so it explicitly prefixes the value coming from
# receiver.
#
def print_cool
inner.format do |ic|
puts "#{ name } - #{ age } - #{ ic.birth_year }"
end
end
# This evaluates self in the context of the caller
# but it fails to identify the birth year's owner.
# So, ruby thinks the caller owns birth_year, which
# is not true, hence the error.
#
def print_notcool
inner.format do |ic|
puts "#{ name } - #{ age } - #{ birth_year }"
end
end
# This evaluates self in the context of the receiver,
# so the birth year is cool but ruby has no idea what
# name and age mean now, hence the error.
#
def print_alsonotcool
inner.format do
puts "#{ name } - #{ age } - #{ birth_year }"
end
end
end
You Didn't Ask, But I'd Appreciate Your Opinion
FWIW, I realise this feature of Caracal is not documented in any way. Probably it should be. But self
is a sufficient advanced concept that you're the first person to ask me about scoping in a non-trivial way.
I genuinely don't think there's a great, auto-magical way to spare advanced users from managing scope explicitly, but I'm 100% open to a comprehensively designed alternative to what I've implemented.
I'm also not clear how best to even write up this discussion in a way that an ordinary ruby user would understand. From what I can tell, most Caracal users are building relatively simple, top-down Word documents through Rails, so I typically advise them to localise their values into simple strings/objects and reference those in their blocks. (The instance_eval
calls pick these data structures fine.) My sense is if I started delving into scoping and self
, I'd lose most of them immediately, you know?
A Practical Solution
I'm going to close this issue, so I wanted to follow-up in case you were still interested. (If not, that's also 100% fine.)
I added a link to this discussion in the README to help others with lexical scoping questions. I'm also going to explicitly answer your immediate question so that the issue will include a "solution" per se.
The ruby issue in your example is that the variables are implicitly referencing self
.
But by the time the interpreter encounters name
and age
, self
is no longer a instance of MyClass but rather an instance of Caracal ListItem. Caracal's ListItem model has no idea what name
and age
mean.
To get the example to work, we need to tell the interpreter explicitly which object owns name
and age
. To do so, we can employ a strategy familiar to javascripters:
class MyClass
attr_reader :name, :age
def initialize(name, age)
@name = name
@age = age
end
def create_doc
obj = self
Caracal::Document.save('my_file.docx') do |document|
document.h1 'Example'
document.ul do
li "Name: #{obj.name}"
li "Age: #{obj.age}"
end
end
end
end
MyClass.new('john', 30).create_doc
hey, I had the same problem and had a hard time finding this issue, I believe it would be nice to add that to the readme.
Hi, @Uelb. There is a direct link to this issue in the README under a section named Using Variables. Were you recommending something other than that?
Actually I missed that and it may be enough ! Thanks. Maybe a better way would be to directly include the bad and the good example in the readme.
Hi, this could also work using
@@name = name
@@age = age
inside the initialize method and then using @@name
and @@age
in your main code.