I’m new to Crystal but fairly experienced in Ruby, and I’m trying to understand if macros are capable of expanding a string representing a complete logical expression, or just a single AST node? (yes, I am in effect looking for eval, but in my case all the strings will be available at comple time).
For example, if I pass a string like lot_width > 6.0 to a macro to be interpolated, it of course just interpolates it as a string. Is there a way to parse a string as AST nodes, and then concatenate them in a macro?
The motivation for all this is that I have Toronto’s zoning bylaw saved a YAML document complete with section/article/sentence hierarchy, the literial text of the bylaw, and expressions that translate this confusing, poorly written legalese into code. It’s quite a lot of content and I don’t want to be separating the code from its location in the bylaw, as the numbering system is ultimately what is going to identify the expressions and link them together.
I may be going about this the wrong way, any suggestions are welcome! Thank you
Macros can almost certainly do what you’re looking for. You may need to define a preprocessor to generate the Crystal code, which is just a simple Crystal program whose STDOUT is injected into the code at the location of the macro call, like HTML in old-school cgi-bin scripts.
I don’t know what your data structures look like, but let’s say this is your YAML:
- title: Example Section
articles:
- title: Example article
sentences:
- text: Something something commercial zones
# This is the Crystal code that will be run when checking this law on this zone
code: zone.type.commercial?
And then let’s say you have this preprocessor in process.cr:
require "yaml"
sections = File.open ARGV[0] do |file|
Array(Section).from_yaml file
end
puts '['
sections.each do |section|
puts "Section.new(title: #{section.title.inspect}, articles: ["
section.articles.each do |article|
puts " Article.new(title: #{article.title.inspect}, sentences: ["
article.sentences.each do |sentence|
puts " Sentence.new("
puts " text: #{sentence.text.inspect},"
puts " code: Proc(Zone, Bool).new { |zone| #{sentence.code} },"
puts " ),"
end
puts " ]),"
end
puts "]),"
end
puts ']'
# These are for the code generator only and reflects the schema of the YAML bylaws. The
# application code will have its own concepts for each one.
struct Section
include YAML::Serializable
getter title : String
getter articles : Array(Article)
end
struct Article
include YAML::Serializable
getter title : String
getter sentences : Array(Sentence)
end
struct Sentence
include YAML::Serializable
getter text : String
getter code : String
end
Then your application code can look like this:
module ZoningBylaws
macro parse(filename)
# Change `./process` with the path to your preprocessor file, without the `.cr`
{{ run "./process", filename }}
end
end
# Using the macro above to parse a YAML file that we have in our project
sections = ZoningBylaws.parse("bylaws.yaml")
commercial = Zone.new(
name: "Example commercial zone",
type: :commercial,
)
residential = Zone.new(
name: "Example residential zone",
type: :residential,
)
p! sections[0].articles[0].sentences[0].check commercial
p! sections[0].articles[0].sentences[0].check residential
# Similar data structures to what's in `process.cr`, but meant to be used at
# run time rather than compile time.
record Section, title : String, articles : Array(Article)
record Article, title : String, sentences : Array(Sentence)
record Sentence, text : String, code : Proc(Zone, Bool) do
def check(zone : Zone)
code.call zone
end
end
record Zone, name : String, type : Type do
enum Type
Residential
Commercial
Industrial
end
end
Changing the code property in the YAML will change the output of the application code. Effectively you’re writing Crystal code in YAML. This also means that a problem in your YAML can break compilation in Crystal, but it lets you define your behavior for specific content-addressable paths through your data structure (such as “section 13, article 45, sentence 3”) in a way that can be more easily automated when the laws change.
Also, I meant to mention in my previous post but I apparently lost this while editing it: I really like your idea of writing certain types of logic this way and I may end up doing something like this in some of my own projects.
Amazing, thank you so much to both of you - #id was what I was looking for, but the preprocessor is really the right way to handle the problem as a whole. It also occurs to me that I can preprocess the YAML ‘source’ file into another crystal file and just ‘require’ it rather than going through macros, as impressive as they are.
I got the idea from reactive front-end frameworks like solid-js which build a directed acyclic graph of signal primitives. I should be able to sort the graph into an evaluation order and get reliable checks of zoning compliance while also telling the architect (me) exactly what information I need to prove compliance.