Skip to content

String interpolation list item text #71

Description

@arvanasse

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.

Activity

  1. jdugan commented on Oct 12, 2017

    @jdugan
    Contributor

    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?

  2. jdugan commented on Jan 3, 2018

    @jdugan
    Contributor

    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
  3. Uelb commented on Jul 1, 2019

    @Uelb

    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.

  4. jdugan commented on Jul 4, 2019

    @jdugan
    Contributor

    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?

  5. Uelb commented on Jul 5, 2019

    @Uelb

    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.

  6. koutzasnew commented on Dec 9, 2020

    @koutzasnew

    Hi, this could also work using

    @@name = name
    @@age = age
    

    inside the initialize method and then using @@name and @@age in your main code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions