Skip to content

Commit 96be9e5

Browse files
committed
Code loading: add support for "portable scripts".
These are jl files that have project and manifest embedded inside them, similar to how Pluto notebooks have done for quite a while. The delimiters are `#!manifest begin` and `#!manifest end` (analogous for project data) and the content can either have single line comments or be inside a multi line comment. When the active project is set to a portable script the project and manifest data will be read from the inlined toml data. Starting julia with a file (`julia file.jl`) will set the file as the active project if julia detects that it is a portable script.
1 parent ec5cf08 commit 96be9e5

File tree

10 files changed

+421
-31
lines changed

10 files changed

+421
-31
lines changed

base/client.jl

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,11 @@ function exec_options(opts)
275275
# remove filename from ARGS
276276
global PROGRAM_FILE = arg_is_program ? popfirst!(ARGS) : ""
277277

278+
if arg_is_program && PROGRAM_FILE != "-" && Base.active_project(false) === nothing
279+
script_path = abspath(PROGRAM_FILE)
280+
Base.has_inline_project(script_path) && Base.set_active_project(script_path)
281+
end
282+
278283
# Load Distributed module only if any of the Distributed options have been specified.
279284
distributed_mode = (opts.worker == 1) || (opts.nprocs > 0) || (opts.machine_file != C_NULL)
280285
if distributed_mode

base/initdefs.jl

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,11 @@ function load_path_expand(env::AbstractString)::Union{String, Nothing}
291291
# Expand trailing relative path
292292
dir = dirname(program_file)
293293
dir = env != "@script" ? (dir * env[length("@script")+1:end]) : dir
294-
return current_project(dir)
294+
project = current_project(dir)
295+
if project === nothing && env == "@script" && Base.has_inline_project(program_file)
296+
return abspath(program_file)
297+
end
298+
return project
295299
end
296300
env = replace(env, '#' => VERSION.major, count=1)
297301
env = replace(env, '#' => VERSION.minor, count=1)
@@ -326,7 +330,9 @@ load_path_expand(::Nothing) = nothing
326330
"""
327331
active_project()
328332
329-
Return the path of the active `Project.toml` file. See also [`Base.set_active_project`](@ref).
333+
Return the path of the active project (either a `Project.toml` file or a julia
334+
file when using a [portable script](@ref portable-scripts)).
335+
See also [`Base.set_active_project`](@ref).
330336
"""
331337
function active_project(search_load_path::Bool=true)
332338
for project in (ACTIVE_PROJECT[],)
@@ -355,7 +361,9 @@ end
355361
"""
356362
set_active_project(projfile::Union{AbstractString,Nothing})
357363
358-
Set the active `Project.toml` file to `projfile`. See also [`Base.active_project`](@ref).
364+
Set the active `Project.toml` file to `projfile`. The `projfile` can be a path to a traditional
365+
`Project.toml` file, a [portable script](@ref portable-scripts) with inline metadata, or `nothing`
366+
to clear the active project. See also [`Base.active_project`](@ref).
359367
360368
!!! compat "Julia 1.8"
361369
This function requires at least Julia 1.8.
@@ -376,6 +384,7 @@ end
376384
active_manifest(project_file::AbstractString)
377385
378386
Return the path of the active manifest file, or the manifest file that would be used for a given `project_file`.
387+
When a [portable script](@ref portable-scripts) is active, this returns the script path itself.
379388
380389
In a stacked environment (where multiple environments exist in the load path), this returns the manifest
381390
file for the primary (active) environment only, not the manifests from other environments in the stack.

base/loading.jl

Lines changed: 150 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -212,22 +212,20 @@ mutable struct CachedTOMLDict
212212
size::Int64
213213
hash::UInt32
214214
d::Dict{String, Any}
215+
kind::Symbol # :full (regular TOML), :project, :manifest (inline)
215216
end
216217

217-
function CachedTOMLDict(p::TOML.Parser, path::String)
218+
function CachedTOMLDict(p::TOML.Parser, path::String, kind)
218219
s = stat(path)
219-
content = read(path)
220+
content = if kind === :full
221+
String(read(path))
222+
else
223+
String(extract_inline_section(path, kind))
224+
end
220225
crc32 = _crc32c(content)
221-
TOML.reinit!(p, String(content); filepath=path)
226+
TOML.reinit!(p, content; filepath=path)
222227
d = TOML.parse(p)
223-
return CachedTOMLDict(
224-
path,
225-
s.inode,
226-
s.mtime,
227-
s.size,
228-
crc32,
229-
d,
230-
)
228+
return CachedTOMLDict(path, s.inode, s.mtime, s.size, crc32, d, kind)
231229
end
232230

233231
function get_updated_dict(p::TOML.Parser, f::CachedTOMLDict)
@@ -236,20 +234,126 @@ function get_updated_dict(p::TOML.Parser, f::CachedTOMLDict)
236234
# identical but that is solvable by not doing in-place updates, and not
237235
# rapidly changing these files
238236
if s.inode != f.inode || s.mtime != f.mtime || f.size != s.size
239-
content = read(f.path)
240-
new_hash = _crc32c(content)
237+
file_content = read(f.path)
238+
new_hash = _crc32c(file_content)
241239
if new_hash != f.hash
242240
f.inode = s.inode
243241
f.mtime = s.mtime
244242
f.size = s.size
245243
f.hash = new_hash
246-
TOML.reinit!(p, String(content); filepath=f.path)
244+
245+
# Extract the appropriate TOML content based on kind
246+
toml_content = if f.kind == :full
247+
String(file_content)
248+
else
249+
String(extract_inline_section(f.path, f.kind))
250+
end
251+
252+
TOML.reinit!(p, toml_content; filepath=f.path)
247253
return f.d = TOML.parse(p)
248254
end
249255
end
250256
return f.d
251257
end
252258

259+
260+
function extract_inline_section(path::String, type::Symbol)
261+
buf = IOBuffer()
262+
start_fence = "#!$type begin"
263+
end_fence = "#!$type end"
264+
state = :none
265+
multiline_mode = false
266+
in_multiline = false
267+
268+
for (lineno, line) in enumerate(eachline(path))
269+
stripped = lstrip(line)
270+
state == :done && break
271+
272+
if startswith(stripped, start_fence)
273+
state = :reading_first
274+
continue
275+
elseif startswith(stripped, end_fence)
276+
state = :done
277+
continue
278+
elseif state === :reading_first
279+
# First line determines the format
280+
if startswith(stripped, "#=")
281+
multiline_mode = true
282+
state = :reading
283+
# Check if the opening #= and closing =# are on the same line
284+
if endswith(rstrip(stripped), "=#")
285+
# Single-line multi-line comment
286+
content = rstrip(stripped)[3:end-2]
287+
write(buf, content)
288+
in_multiline = false
289+
else
290+
# Multi-line comment continues
291+
in_multiline = true
292+
content = stripped[3:end] # Remove #= from start
293+
write(buf, content)
294+
write(buf, '\n')
295+
end
296+
else
297+
# Line-by-line format
298+
multiline_mode = false
299+
state = :reading
300+
# Process this first line
301+
if startswith(stripped, '#')
302+
toml_line = lstrip(chop(stripped, head=1, tail=0))
303+
write(buf, toml_line)
304+
else
305+
write(buf, line)
306+
end
307+
write(buf, '\n')
308+
end
309+
elseif state === :reading
310+
if multiline_mode && in_multiline
311+
# In multi-line comment mode, look for closing =#
312+
if endswith(rstrip(stripped), "=#")
313+
# Found closing delimiter
314+
content = rstrip(stripped)[1:end-2] # Remove =# from end
315+
write(buf, content)
316+
in_multiline = false
317+
else
318+
# Still inside multi-line comment
319+
write(buf, line)
320+
write(buf, '\n')
321+
end
322+
elseif !multiline_mode
323+
# Line-by-line comment mode, strip # from each line
324+
if startswith(stripped, '#')
325+
toml_line = lstrip(chop(stripped, head=1, tail=0))
326+
write(buf, toml_line)
327+
else
328+
write(buf, line)
329+
end
330+
write(buf, '\n')
331+
end
332+
# If multiline_mode && !in_multiline, the multiline comment has ended.
333+
# Don't accumulate any more content; just wait for the end fence.
334+
end
335+
end
336+
337+
if state === :done
338+
return strip(String(take!(buf)))
339+
elseif state === :none
340+
return ""
341+
else
342+
error("incomplete inline $type block in $path (missing #!$type end)")
343+
end
344+
end
345+
346+
function has_inline_project(path::String)::Bool
347+
for line in eachline(path)
348+
stripped = lstrip(line)
349+
if startswith(stripped, "#!project begin")
350+
return true
351+
end
352+
end
353+
return false
354+
end
355+
356+
253357
struct LoadingCache
254358
load_path::Vector{String}
255359
dummy_uuid::Dict{String, UUID}
@@ -273,26 +377,38 @@ TOMLCache(p::TOML.Parser, d::Dict{String, Dict{String, Any}}) = TOMLCache(p, con
273377

274378
const TOML_CACHE = TOMLCache(TOML.Parser{nothing}())
275379

276-
parsed_toml(project_file::AbstractString) = parsed_toml(project_file, TOML_CACHE, require_lock)
277-
function parsed_toml(project_file::AbstractString, toml_cache::TOMLCache, toml_lock::ReentrantLock)
380+
parsed_toml(toml_file::AbstractString; manifest::Bool=false, project::Bool=!manifest) =
381+
parsed_toml(toml_file, TOML_CACHE, require_lock; manifest=manifest, project=project)
382+
function parsed_toml(toml_file::AbstractString, toml_cache::TOMLCache, toml_lock::ReentrantLock;
383+
manifest::Bool=false, project::Bool=!manifest)
384+
manifest && project && throw(ArgumentError("cannot request both project and manifest TOML"))
278385
lock(toml_lock) do
386+
# Portable script?
387+
if endswith(toml_file, ".jl") && isfile_casesensitive(toml_file)
388+
kind = manifest ? :manifest : :project
389+
cache_key = "$(toml_file)::$(kind)"
390+
else
391+
kind = :full
392+
cache_key = toml_file
393+
end
394+
279395
cache = LOADING_CACHE[]
280-
dd = if !haskey(toml_cache.d, project_file)
281-
d = CachedTOMLDict(toml_cache.p, project_file)
282-
toml_cache.d[project_file] = d
396+
dd = if !haskey(toml_cache.d, cache_key)
397+
d = CachedTOMLDict(toml_cache.p, toml_file, kind)
398+
toml_cache.d[cache_key] = d
283399
d.d
284400
else
285-
d = toml_cache.d[project_file]
401+
d = toml_cache.d[cache_key]
286402
# We are in a require call and have already parsed this TOML file
287403
# assume that it is unchanged to avoid hitting disk
288-
if cache !== nothing && project_file in cache.require_parsed
404+
if cache !== nothing && cache_key in cache.require_parsed
289405
d.d
290406
else
291407
get_updated_dict(toml_cache.p, d)
292408
end
293409
end
294410
if cache !== nothing
295-
push!(cache.require_parsed, project_file)
411+
push!(cache.require_parsed, cache_key)
296412
end
297413
return dd
298414
end
@@ -656,6 +772,8 @@ function env_project_file(env::String)::Union{Bool,String}
656772
project_file = locate_project_file(env)
657773
elseif basename(env) in project_names && isfile_casesensitive(env)
658774
project_file = env
775+
elseif endswith(env, ".jl") && isfile_casesensitive(env)
776+
project_file = has_inline_project(env) ? env : false
659777
else
660778
project_file = false
661779
end
@@ -897,7 +1015,8 @@ function project_file_manifest_path(project_file::String)::Union{Nothing,String}
8971015
manifest_path === missing || return manifest_path
8981016
end
8991017
dir = abspath(dirname(project_file))
900-
isfile_casesensitive(project_file) || return nothing
1018+
has_file = isfile_casesensitive(project_file)
1019+
has_file || return nothing
9011020
d = parsed_toml(project_file)
9021021
base_manifest = workspace_manifest(project_file)
9031022
if base_manifest !== nothing
@@ -911,6 +1030,10 @@ function project_file_manifest_path(project_file::String)::Union{Nothing,String}
9111030
manifest_path = manifest_file
9121031
end
9131032
end
1033+
if manifest_path === nothing && endswith(project_file, ".jl") && has_file
1034+
# portable script: manifest is the same file as the project file
1035+
manifest_path = project_file
1036+
end
9141037
if manifest_path === nothing
9151038
for mfst in manifest_names
9161039
manifest_file = joinpath(dir, mfst)
@@ -1038,7 +1161,7 @@ dep_stanza_get(stanza::Nothing, name::String) = nothing
10381161
function explicit_manifest_deps_get(project_file::String, where::PkgId, name::String)::Union{Nothing,PkgId}
10391162
manifest_file = project_file_manifest_path(project_file)
10401163
manifest_file === nothing && return nothing # manifest not found--keep searching LOAD_PATH
1041-
d = get_deps(parsed_toml(manifest_file))
1164+
d = get_deps(parsed_toml(manifest_file; manifest=true))
10421165
for (dep_name, entries) in d
10431166
entries::Vector{Any}
10441167
for entry in entries
@@ -1111,7 +1234,7 @@ function explicit_manifest_uuid_path(project_file::String, pkg::PkgId)::Union{No
11111234
manifest_file = project_file_manifest_path(project_file)
11121235
manifest_file === nothing && return nothing # no manifest, skip env
11131236

1114-
d = get_deps(parsed_toml(manifest_file))
1237+
d = get_deps(parsed_toml(manifest_file; manifest=true))
11151238
entries = get(d, pkg.name, nothing)::Union{Nothing, Vector{Any}}
11161239
if entries !== nothing
11171240
for entry in entries
@@ -1540,7 +1663,7 @@ function insert_extension_triggers(env::String, pkg::PkgId)::Union{Nothing,Missi
15401663
project_file isa String || return nothing
15411664
manifest_file = project_file_manifest_path(project_file)
15421665
manifest_file === nothing && return
1543-
d = get_deps(parsed_toml(manifest_file))
1666+
d = get_deps(parsed_toml(manifest_file; manifest=true))
15441667
for (dep_name, entries) in d
15451668
entries::Vector{Any}
15461669
for entry in entries
@@ -2516,7 +2639,7 @@ function find_unsuitable_manifests_versions()
25162639
project_file isa String || continue # no project file
25172640
manifest_file = project_file_manifest_path(project_file)
25182641
manifest_file isa String || continue # no manifest file
2519-
m = parsed_toml(manifest_file)
2642+
m = parsed_toml(manifest_file; manifest=true)
25202643
man_julia_version = get(m, "julia_version", nothing)
25212644
man_julia_version isa String || @goto mark
25222645
man_julia_version = VersionNumber(man_julia_version)

base/precompilation.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ function ExplicitEnv(envpath::String)
108108
end
109109

110110
manifest = project_file_manifest_path(envpath)
111-
manifest_d = manifest === nothing ? Dict{String, Any}() : parsed_toml(manifest)
111+
manifest_d = manifest === nothing ? Dict{String, Any}() : parsed_toml(manifest; manifest=true)
112112

113113
# Dependencies in a manifest can either be stored compressed (when name is unique among all packages)
114114
# in which case it is a `Vector{String}` or expanded where it is a `name => uuid` mapping.

doc/src/manual/code-loading.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,47 @@ are stored in the manifest file in the section for that package. The dependency
397397
a package are the same as for its "parent" except that the listed triggers are also considered as
398398
dependencies.
399399

400+
### [Portable scripts](@id portable-scripts)
401+
402+
Julia also understands *portable scripts*: scripts that embed their own `Project.toml` (and optionally `Manifest.toml`) so they can be executed as self-contained environments. To do this, place TOML data inside comment fences named `#!project` and `#!manifest`:
403+
404+
```julia
405+
#!project begin
406+
# name = "HelloApp"
407+
# uuid = "9c5fa7d8-7220-48e8-b2f7-0042191c5f6d"
408+
# version = "0.1.0"
409+
# [deps]
410+
# Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a"
411+
#!project end
412+
413+
#!manifest begin
414+
# [[deps]]
415+
# name = "Markdown"
416+
# uuid = "d6f4376e-aef5-505a-96c1-9c027394607a"
417+
# version = "1.0.0"
418+
#!manifest end
419+
420+
using Markdown
421+
println(md"# Hello, single-file world!")
422+
```
423+
424+
Lines inside the fenced blocks may either start with `#` (as in the example), be plain TOML, or be wrapped in multi-line comment delimiters `#= ... =#`:
425+
426+
```julia
427+
#!project begin
428+
#=
429+
name = "HelloApp"
430+
uuid = "9c5fa7d8-7220-48e8-b2f7-0042191c5f6d"
431+
version = "0.1.0"
432+
[deps]
433+
Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a"
434+
=#
435+
#!project end
436+
```
437+
438+
439+
Running `julia hello.jl` automatically activates the embedded project. The script path becomes the active project entry in `LOAD_PATH`, so package loading works exactly as if `Project.toml` and `Manifest.toml` lived next to the script. The `--project=@script` flag also expands to the script itself when no on-disk project exists but inline metadata is present.
440+
400441
### [Workspaces](@id workspaces)
401442

402443
A project file can define a workspace by giving a set of projects that is part of that workspace:

0 commit comments

Comments
 (0)