Macro hook for full file

Ok, this one is probably going to be horrible, but hear me out. We are using Crystal a lot to create DSLs, and I wanted to put this out there and ask if other DSL authors would find this useful, too?

One of the DSLs should have the following structure:

require "horrible/dsl"

namespace Some::Namespace

# DSL Stuff goes here, all top level

We now need to wrap everything following namespace in some blocks (module, class, whatever). So we would need a hook that calls the namespace macro with everything below it as a block. I guess right now, there’s no way to do this.

For added horribleness, there could be multiple namespaces, meaning each namespace macro would get as block the content up to the next namespace, or the end of the file.

Sorry for inducing any pain, but them’s the requirements…

Wouldn’t it be easier/make more sense for the DSL to be like:

namespace Some::Namespace do
  # DSL stuff goes here to show it's in `Some::Namespace`
end

Which would work today without any language changes.

Yes, it definitely would. However, the requirements are to avoid having to indent basically the whole file.

If module defines the DSL like

module DSL
  def say(m)
    puts m
  end
end

You can then write

extend DSL

say "hi"

Unfortunately requiring the last file will pollute any dependent: the extend DSL will affect them, it is not scoped per file.

I wonder if you could do something somewhat hacky like create a map via mutable constant at compile time via __FILE__ and the passed namespace such that when you call namespace DSL macro it does something like NAMESPACE_MAP = {"test.cr" => ["Some", "Namespace"]} that other DSL calls lookup, join via :: and prepend to their types.

Thanks, I’ll try that. Currently I’m also exploring registering file-private macros for all DSL commands, which also falls under the “somewhat hacky” category.

I was just wondering if we were alone with this, or if it was something worth formalizing.

Usually with ... yield ... is very good for DSL imo. Is what it allows you to have fallback of the binding context. You get control over how say is resolved in the following snippet.

with_dsl do
  say "hi"
end

Yet RFC: `with ... yield` replacement .


If there is a strong requirement of code being not nested then I assume that that code is executed in some very precise context. The moment you require that file its top level code is executed.

There are couple of alternatives but they will have different friction depending no the rest of the project probably.

Assuming user.cr is the code that consumes the dsl.cr,

Q: Do you need for user.cr to compile/type-check by itself? Or could it be a partial program?

If yes, the following is a way to have a main.cr that is the actual entry point.

# file: dsl.cr
module DSL
  def say(m)
    puts m
  end
end
# file: user.cr
say "hi"
# file: main.cr
require "./dsl"

module M
  extend DSL

  def self.user
    {{ read_file("#{__DIR__}/user.cr").id }}
  end
end

M.user

Some benefits is that you can change the interpreter of the dsl :nerd_face:
Drawback is that user.cr does not compile/checks by itself and tooling might crumble.
But most importantly you can’t use the full power of crystal in user.cr: require, type definition, methods will break. Essentially you are putting the body of the method in a separate file. Is that enough? Probably not…


If the dsl interpreter can be fixed having some private def sounds good to avoid pollution, yet you still have no control on when to execute that.


Maybe if you share some more context of what the dsl is for there are other alternatives. And I am curious to know why is the non-nested a hard requirement.