Skip to content

Commit

Permalink
add: support for a subset of Makefile (closes #68)
Browse files Browse the repository at this point in the history
  • Loading branch information
metaist committed Aug 22, 2024
1 parent c4612de commit e2d9187
Show file tree
Hide file tree
Showing 11 changed files with 368 additions and 23 deletions.
2 changes: 2 additions & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"version": "0.2",
"language": "en",
"words": [
"akefile",
"capsys",
"codeql",
"docopt",
Expand All @@ -25,6 +26,7 @@
"pyproject",
"pyright",
"pytest",
"RECIPEPREFIX",
"setuptools",
"tomli",
"tomllib",
Expand Down
3 changes: 3 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true

[Makefile]
indent_style = tab

[*.go]
indent_style = tab

Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ ds test
- **PHP** (`composer.json`): [`composer run-script`][composer run-script]
- **Rust** (`Cargo.toml`): [`cargo run-script`][cargo run-script]

**Experimental**: We also support an extremely small subset of the [`Makefile`](./Makefile) format (see [#68]).

See: [Inspirations](#inspirations)

🗂️ **Add monorepo/workspace support anywhere**<br />
Expand All @@ -99,6 +101,7 @@ Currently working on removing all of these (see [#46]):

- Not Supported: [Lifecycle Events](#not-supported-lifecycle-events)
- Not Supported: [`call` Tasks](#not-supported-call-tasks)
- Partial Support: [`Makefile` format][#68] (see [#68])
- In Progress: [Shell Completions][#44] (see [#44])
- In Progress: [Remove Python Dependency][#46] (see [#46])

Expand Down Expand Up @@ -216,6 +219,8 @@ Dev scripts act as another form of documentation that helps developers understan
- **Rust**: `Cargo.toml` under `[package.metadata.scripts]` or `[workspace.metadata.scripts]`
- **Other**: `ds.toml` under `[scripts]`

**Experimental**: We support an extremely small subset of the [`Makefile`](./Makefile) format (see [#68]).

Read more:

- [Example configuration files][example-tasks]
Expand Down Expand Up @@ -635,6 +640,7 @@ I've used several task runners, usually as part of build tools. Below is a list
[#46]: https://github.com/metaist/ds/issues/46
[#51]: https://github.com/metaist/ds/issues/51
[#54]: https://github.com/metaist/ds/issues/54
[#68]: https://github.com/metaist/ds/issues/68
[ant]: https://en.wikipedia.org/wiki/Apache_Ant
[bun run]: https://bun.sh/docs/cli/run
[bun]: https://en.wikipedia.org/wiki/Bun_(software)
Expand Down
103 changes: 103 additions & 0 deletions examples/formats/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Example: `make` configuration file.
# See: https://www.gnu.org/software/make/manual/make.html
# NOTE: There are significant differences between how we parse this file
# and how `make` parses it. Every supported feature is listed in this file.
#
# Here are some things that are known to be unsupported:
# Not supported: (03.01.01) .POSIX
# Not supported: (03.03.00) include
# Not supported: (04.09.00) special built-in target names
# Not supported: (04.10.00) grouped targets
# Not supported: (04.11.00) multiple rules for one target
# Not supported: (04.13.00) double-colon rules
# Not supported: (06.00.00) variables
# Not supported: (07.00.00) conditionals
# Not supported: (08.00.00) functions
# Not supported: (10.00.00) implicit rules

# Supported: change prefix
.RECIPEPREFIX = > # sets to greater than symbol

# both of these reset back to tab
.RECIPEPREFIX
.RECIPEPREFIX=

# Ignored: (04.06.00) `.PHONY` target
# We treat all targets as .PHONY
.PHONY: basic1 basic2

# A basic command runs one or more shell commands.
basic1:
echo 'Hello'
echo 'World'

basic2: # put helpful descriptions here
ls -la

# A composite command calls its prerequisites before it is executed.
composite1: basic1 basic2

# You can put a space before the colon.
composite2 : basic1
echo 'Do this afterwards'

# We support line continuations both in the prerequisites and recipes.
composite3: basic1 \
basic2
echo "This is a very \
long line."


# Argument Interpolation: pass arguments to tasks (use pdm-style)
# supply defaults
test1:
pytest {args:src test}

# interpolate the first argument (required)
# and then interpolate the remaining arguments, if any
lint1:
ruff check $1 {args:}

# you can call other tasks from the recepies and provide arguments
lint2:
lint1 . --fix

# Environment Variables: pass environment variables to tasks
# Files are resolved relative to the configuration file.
# If the .env file has "FLASK_PORT=8080", the following are equivalent.
env1:
FLASK_PORT=8080
flask run --debug

env2:
source .env
flask run --debug

# Error Suppression: run tasks even when previous tasks fail
will_fail:
exit 1 # will exit with error code 1

keep_going:
-exit 1 # Makefile-style, note the dash symbol

# suppress within a composite
keep_going3: +will_fail
echo Works

# Working Directory: where does a task run?
# Run in the directory one level up from the configuration file.
working:
cd ..
ls -la

# Supported: multiple targets
# https://www.gnu.org/software/make/manual/make.html#Multiple-Targets
big little: text.g
generate $< -$@ > $@output

# Partially supported: automatic variables
# https://www.gnu.org/software/make/manual/make.html#Automatic-Variables
# - $@ name of the target
# - $< first prerequisite
# - $? all prerequisites (not supported: "newer than target")
# - $^ all prerequisites only once
4 changes: 4 additions & 0 deletions examples/formats/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,7 @@ This folder contains minimal examples of supported configuration file formats.
## Other

For all other languages and tools, use [`ds.toml`](./ds.toml).

**Experimental**: We also support an extremely small subset of the [`Makefile`](./Makefile) format (see [#68]).

[#68]: https://github.com/metaist/ds/issues/68
2 changes: 1 addition & 1 deletion examples/formats/ds.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ basic2 = { cmd = "ls -la" } # string-version
basic3 = { cmd = ["ls", "-la"] } # array-version
basic4 = { shell = "ls -la" }

# Add a description tasks.
# Add task descriptions.
basic5 = { cmd = "ls -la", help = "list files" }
basic6.shell = "ls -la"
basic6.help = "list files"
Expand Down
22 changes: 15 additions & 7 deletions src/ds/configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import field
from fnmatch import fnmatch
from pathlib import Path
from typing import Any
from typing import Callable
Expand All @@ -30,15 +31,20 @@
from .symbols import TASK_DISABLED
from .tasks import Task
from .tasks import Tasks
from .env import makefile_loads

GlobMatches = Dict[Path, bool]
"""Mapping a path to whether it should be included."""

Loader = Callable[[str], Dict[str, Any]]
"""A loader takes text and returns a mapping of strings to values."""

LOADERS: Dict[str, Loader] = {".toml": toml.loads, ".json": json.loads}
"""Mapping of file extensions to string load functions."""
LOADERS: Dict[str, Loader] = {
"*.json": json.loads,
"*.toml": toml.loads,
"*[Mm]akefile": makefile_loads,
}
"""Mapping of file patterns to load functions."""

# NOTE: Used by cog in README.md
SEARCH_FILES = [
Expand All @@ -48,6 +54,8 @@
"composer.json",
"package.json",
"pyproject.toml",
"Makefile",
"makefile",
]
"""Search order for configuration file names."""

Expand All @@ -59,6 +67,7 @@
"tool.rye.scripts", # pyproject.toml
"package.metadata.scripts", # Cargo.toml
"workspace.metadata.scripts", # Cargo.toml
"Makefile", # Makefile
]
"""Search order for configuration keys."""

Expand Down Expand Up @@ -92,11 +101,10 @@ class Config:
@staticmethod
def load(path: Path) -> Config:
"""Try to load a configuration file."""
if path.suffix not in LOADERS:
raise LookupError(f"Not sure how to read a {path.suffix} file: {path}")

config = LOADERS[path.suffix](path.read_text())
return Config(path, config)
for pattern, loader in LOADERS.items():
if fnmatch(path.name, pattern):
return Config(path, loader(path.read_text()))
raise LookupError(f"Not sure how to read file: {path}")

def parse(self, require_workspace: bool = False) -> Config:
"""Parse a configuration file."""
Expand Down
141 changes: 141 additions & 0 deletions src/ds/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import Any
from typing import Dict
from typing import Iterator
from typing import Tuple
from typing import List
from typing import Mapping
from typing import Match
Expand Down Expand Up @@ -279,3 +280,143 @@ def wrap_cmd(cmd: str, width: int = DEFAULT_WIDTH) -> str:
result.append(line)

return "".join(result).replace("\n", f"\n{space}").strip()


def makefile_loads(text: str, debug: bool = False) -> Dict[str, Dict[str, Any]]:
"""Load a `Makefile`."""
# debug = True
print("WARNING: Makefile support is experimental.")

result: Dict[str, Dict[str, Any]] = {}
prefix = "\t"
n, lines = 0, text.split("\n")
targets: List[str] = []
in_recipe = False

def _log(*args: Any, **kwargs: Any) -> None:
if debug:
print(*args, **kwargs)

def _strip_comment(line: str) -> str:
if "#" in line:
line = line[: line.index("#")]
return line

def _key_val(line: str) -> Tuple[str, str]:
key, val = "", ""
line = _strip_comment(line)
if " = " in line: # spaces around equals
key, val = line.split(" = ", 1)
elif "=" in line: # no spaces
key, val = line.split("=", 1)
else:
key, val = line, ""
return key, val

while lines:
n, line = n + 1, lines.pop(0)
_log(f"{n:03}/{len(lines):03}", repr(line))

# 5.1: "Blank lines and lines of just comments may appear among the
# recipe lines; they are ignored. [...] A comment in a recipe is not
# a `make` comment; it will be passed to the shell as-is."
if in_recipe:
has_prefix, line = starts(line, prefix)
if not has_prefix: # end of recipe
for target in targets:
_log(f"{n:03}|>>>", "end", target, result[target])
targets, in_recipe = [], False
else:
# https://www.gnu.org/software/make/manual/make.html#Splitting-Recipe-Lines
# 5.1.1: "[...] backslash/newline pairs are not removed from the
# recipe. Both the backslash and the newline characters are
# preserved and passed to the shell."
while lines and line.endswith(SHELL_CONTINUE[0]): # merge next line
n, next_line = n + 1, lines.pop(0)
_, next_line = starts(next_line, prefix) # remove prefix
# continuation and new line are preserved
line = line + "\n" + next_line
_log(f"{n:03}/{len(lines):03}", repr(line))

# handle error suppression
if line.startswith("-"):
line = line[1:]
for target in targets:
result[target]["keep_going"] = True
# put the newline back
for target in targets:
result[target]["shell"] += line + "\n"

if not in_recipe:
if not line or line.startswith("#"):
continue

# https://www.gnu.org/software/make/manual/make.html#Splitting-Lines
# 3.1.1: Outside of recipe lines, backslash/newlines are converted
# into a single space character. Once that is done, all whitespace
# around the backslash/newline is condensed into a single space:
# this includes all whitespace preceding the backslash, all
# whitespace at the beginning of the line after the
# backslash/newline, and any consecutive backslash/newline
# combinations."
while lines and line.endswith(SHELL_CONTINUE[0]): # merge next line
n, next_line = n + 1, lines.pop(0)
# whitespace is consolidated
line = line[:-1].rstrip() + " " + next_line.lstrip()
_log(f"{n:03}/{len(lines):03}", repr(line))

if line.startswith(".PHONY"): # we treat all targets as phony
continue
if line.startswith(".RECIPEPREFIX"): # change prefix
_, prefix = _key_val(line)
if not prefix:
prefix = "\t"
if len(prefix) > 1:
prefix = prefix[0]
_log(f"{n:03}|>>>", "prefix", repr(prefix))
continue
if ":" in line: # start recipe
# {target1} {target2} : {dep1} {dep2} ; {cmd1} # {help}
in_recipe = True
value, rest = line.split(":", 1)
targets = value.split()
for target in targets:
# Overwrite previous definition, if any.
result[target] = {"composite": [], "shell": "", "verbatim": True}

# NONSTANDARD: take comment on target line as description
if "#" in rest:
rest, value = rest.split("#", 1)
for target in targets:
result[target]["help"] = value.strip()

# 5.1: "[...] the first recipe line may be attached to the
# target-and-prerequisites line with a semicolon in between."
if ";" in rest:
rest, value = rest.split(";", 1)
for target in targets:
result[target]["shell"] += value + "\n"

# prerequisites
for d in rest.split():
d = d.strip()
if d.startswith("-"):
d = f"+{d[1:]}"
for target in targets:
result[target]["composite"].append(d)
_log(f"{n:03}|>>>", "start", target, result[target])

# https://www.gnu.org/software/make/manual/make.html#Automatic-Variables
for name, rule in result.items():
cmd = rule["shell"]
deps = rule["composite"]

cmd = cmd.replace("$@", name) # name of the rule
if deps:
cmd = cmd.replace("$<", deps[0]) # first prerequisite
cmd = cmd.replace("$?", " ".join(deps)) # all "newer" prerequisites
cmd = cmd.replace("$^", " ".join(set(deps))) # all prerequisites
rule["shell"] = cmd

# print(result)
return {"Makefile": result}
Loading

0 comments on commit e2d9187

Please sign in to comment.