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…
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.
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
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.