I'm currently working on a little hobby project to try and implement something like TaskJuggler in Haskell, mostly as an experiment to play with writing domain specific languages.
My current goal is to have a small DSL for building up the description of a Project
, along with it's associated Task
s. There is no hierarchy yet, though that'll be my next extension. Currently, I have the following data types:
data Project = Project { projectName :: Text
, projectStart :: Day
, projectEnd :: Day
, projectMaxHoursPerDay :: Int
, projectTasks :: [Task]
}
deriving (Eq, Show)
data Task = Task { taskName :: Text }
deriving (Eq, Show)
Nothing too crazy there, I'm sure you will agree.
Now I want to create a DSL to build up projects/tasks. I can use Writer [Task]
monad to build up tasks, but this won't scale well. We might be able to do the following now:
project "LambdaBook" startDate endDate $ do
task "Web site"
task "Marketing"
Where project :: Text -> Date -> Date -> Writer [Task] a
, which runs the Writer
to get a list of tasks, and choses a default value such as 8 for projectMaxHoursPerDay
.
But I will later want to be able to do something like:
project "LambdaBook" $ do
maxHoursPerDay 4
task "Web site"
task "Marketing"
So I'm using maxHoursPerDay
to specify a (future) property about a Project
. I can no longer use a Writer
for this, because [Task]
isn't able to capture everything I need.
I see two possibilities for solving this problem:
I could split Project
into:
data Project = Project { projectName, projectStart, projectEnd, projectProperties }
data ProjectProperties = ProjectProperties { projectMaxHoursPerDay :: Maybe Int
, projectTasks :: [Task]
}
Now I can have an instance Monoid ProjectProperties
. When I run Writer ProjectProperties
I can do all the defaulting I need to build a Project
. I suppose there's no reason that Project
needs to embed ProjectProperties
- it could even have the same definition as above.
Semigroup m => Writer m
While Project
isn't a Monoid
, it can certainly be made into a Semigroup
. Name/start/end are First
, maxHoursPerDay
is Last
, and projectTasks
is [Task]
. We can't have a Writer
monad over a Semigroup
, but we can have a Writer
bindable functor.
With the first solution - a dedicated 'properties' Monoid
- we can use the full power of a monad, at a choice of costs. I could duplicate the overridable properties in Project
and ProjectProperties
, where the latter wraps each property in an appropriate monoid. Or I could just write the monoid once and embed it inside the Project
- though I give up type safety (maxHoursPerDay
must be Just
when I actually produce the project plan!).
A bindable functor removes both the code duplication and retains type safety, but at the immediate cost of giving up syntax sugar, and the potentially longer term cost of being a pain to work with (due to lack of return
/pure
).
I have examples of both approaches at http://hpaste.org/82024 (for bindable functors), and http://hpaste.org/82025 (for the monad approach). These examples go a little beyond what's in this SO post (which was big enough already), and has Resource
along with Task
. Hopefully this will indicate why I need to go as far Bind
(or Monad
) in the DSL.
I'm excited to have even found an applicable use for bindable functors, so I'm happy to hear any thoughts or experience you might have.
data Project maxHours = Project {tasks :: [Task], maxHourLimit :: maxHours}
defProject = Project [] ()
setMaxHours :: Project () -> Project Double
setMaxHours = ...
addTask :: Project a -> Project a
type CompleteProject = Project Double...
runProject :: CompleteProject -> ...
storeProject :: CompleteProject -> ...
You need function composition now, instead of actions in a writer, but this pattern lets you start with a partially populated record, and set those things that need to be set once and only once with plenty of type safety. It even lets you impose constraints on the relationship between various set and unset values in the final result.
An interesting solution that was proposed on Google+ was to use a normal Writer
monad, but using the Endo Project
monoid. Along with lens
, this yields a very nice DSL:
data Project = Project { _projectName :: String
, _projectStart :: Day
, _projectEnd :: Day
, _projectTasks :: [Task]
}
deriving (Eq, Show)
makeLenses ''Project
Along with the operation
task :: String -> ProjectBuilder Task
task name = t <$ mapProject (projectTasks <>~ [t])
where t = Task name []
Which can be used with the original DSL. This is probably the best solution for what I want (though maybe using a monad is just too much of an abuse of syntax anyway).
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With