Skip to content

Commit 9c3d598

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 f8c7b22 commit 9c3d598

File tree

8 files changed

+365
-35
lines changed

8 files changed

+365
-35
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: 153 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -212,22 +212,22 @@ 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 !isfile(path)
221+
"" # A bit fishy maybe...
222+
elseif kind === :full
223+
String(read(path))
224+
else
225+
String(extract_inline_section(path, kind))
226+
end
220227
crc32 = _crc32c(content)
221-
TOML.reinit!(p, String(content); filepath=path)
228+
TOML.reinit!(p, content; filepath=path)
222229
d = TOML.parse(p)
223-
return CachedTOMLDict(
224-
path,
225-
s.inode,
226-
s.mtime,
227-
s.size,
228-
crc32,
229-
d,
230-
)
230+
return CachedTOMLDict(path, s.inode, s.mtime, s.size, crc32, d, kind)
231231
end
232232

233233
function get_updated_dict(p::TOML.Parser, f::CachedTOMLDict)
@@ -236,20 +236,126 @@ function get_updated_dict(p::TOML.Parser, f::CachedTOMLDict)
236236
# identical but that is solvable by not doing in-place updates, and not
237237
# rapidly changing these files
238238
if s.inode != f.inode || s.mtime != f.mtime || f.size != s.size
239-
content = read(f.path)
240-
new_hash = _crc32c(content)
239+
file_content = read(f.path)
240+
new_hash = _crc32c(file_content)
241241
if new_hash != f.hash
242242
f.inode = s.inode
243243
f.mtime = s.mtime
244244
f.size = s.size
245245
f.hash = new_hash
246-
TOML.reinit!(p, String(content); filepath=f.path)
246+
247+
# Extract the appropriate TOML content based on kind
248+
toml_content = if f.kind == :full
249+
String(file_content)
250+
else
251+
String(extract_inline_section(f.path, f.kind))
252+
end
253+
254+
TOML.reinit!(p, toml_content; filepath=f.path)
247255
return f.d = TOML.parse(p)
248256
end
249257
end
250258
return f.d
251259
end
252260

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

274380
const TOML_CACHE = TOMLCache(TOML.Parser{nothing}())
275381

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)
382+
parsed_toml(toml_file::AbstractString; manifest::Bool=false, project::Bool=!manifest) =
383+
parsed_toml(toml_file, TOML_CACHE, require_lock; manifest=manifest, project=project)
384+
function parsed_toml(toml_file::AbstractString, toml_cache::TOMLCache, toml_lock::ReentrantLock;
385+
manifest::Bool=false, project::Bool=!manifest)
386+
manifest && project && throw(ArgumentError("cannot request both project and manifest TOML"))
278387
lock(toml_lock) do
388+
# Portable script?
389+
if endswith(toml_file, ".jl") && isfile_casesensitive(toml_file)
390+
kind = manifest ? :manifest : :project
391+
cache_key = "$(toml_file)::$(kind)"
392+
else
393+
kind = :full
394+
cache_key = toml_file
395+
end
396+
279397
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
398+
dd = if !haskey(toml_cache.d, cache_key)
399+
d = CachedTOMLDict(toml_cache.p, toml_file, kind)
400+
toml_cache.d[cache_key] = d
283401
d.d
284402
else
285-
d = toml_cache.d[project_file]
403+
d = toml_cache.d[cache_key]
286404
# We are in a require call and have already parsed this TOML file
287405
# assume that it is unchanged to avoid hitting disk
288-
if cache !== nothing && project_file in cache.require_parsed
406+
if cache !== nothing && cache_key in cache.require_parsed
289407
d.d
290408
else
291409
get_updated_dict(toml_cache.p, d)
292410
end
293411
end
294412
if cache !== nothing
295-
push!(cache.require_parsed, project_file)
413+
push!(cache.require_parsed, cache_key)
296414
end
297415
return dd
298416
end
@@ -663,6 +781,8 @@ function env_project_file(env::String)::Union{Bool,String}
663781
project_file = locate_project_file(env)
664782
elseif basename(env) in project_names && isfile_casesensitive(env)
665783
project_file = env
784+
elseif endswith(env, ".jl") && isfile_casesensitive(env)
785+
project_file = has_inline_project(env) ? env : false
666786
else
667787
project_file = false
668788
end
@@ -885,7 +1005,8 @@ function project_file_manifest_path(project_file::String)::Union{Nothing,String}
8851005
manifest_path === missing || return manifest_path
8861006
end
8871007
dir = abspath(dirname(project_file))
888-
isfile_casesensitive(project_file) || return nothing
1008+
has_file = isfile_casesensitive(project_file)
1009+
has_file || return nothing
8891010
d = parsed_toml(project_file)
8901011
base_manifest = workspace_manifest(project_file)
8911012
if base_manifest !== nothing
@@ -894,10 +1015,11 @@ function project_file_manifest_path(project_file::String)::Union{Nothing,String}
8941015
explicit_manifest = get(d, "manifest", nothing)::Union{String, Nothing}
8951016
manifest_path = nothing
8961017
if explicit_manifest !== nothing
897-
manifest_file = normpath(joinpath(dir, explicit_manifest))
898-
if isfile_casesensitive(manifest_file)
899-
manifest_path = manifest_file
900-
end
1018+
manifest_path = normpath(joinpath(dir, explicit_manifest))
1019+
end
1020+
if manifest_path === nothing && endswith(project_file, ".jl") && has_file
1021+
# portable script: manifest is the same file as the project file
1022+
manifest_path = project_file
9011023
end
9021024
if manifest_path === nothing
9031025
for mfst in manifest_names
@@ -1026,7 +1148,7 @@ dep_stanza_get(stanza::Nothing, name::String) = nothing
10261148
function explicit_manifest_deps_get(project_file::String, where::PkgId, name::String)::Union{Nothing,PkgId}
10271149
manifest_file = project_file_manifest_path(project_file)
10281150
manifest_file === nothing && return nothing # manifest not found--keep searching LOAD_PATH
1029-
d = get_deps(parsed_toml(manifest_file))
1151+
d = get_deps(parsed_toml(manifest_file; manifest=true))
10301152
for (dep_name, entries) in d
10311153
entries::Vector{Any}
10321154
for entry in entries
@@ -1099,7 +1221,7 @@ function explicit_manifest_uuid_path(project_file::String, pkg::PkgId)::Union{No
10991221
manifest_file = project_file_manifest_path(project_file)
11001222
manifest_file === nothing && return nothing # no manifest, skip env
11011223

1102-
d = get_deps(parsed_toml(manifest_file))
1224+
d = get_deps(parsed_toml(manifest_file; manifest=true))
11031225
entries = get(d, pkg.name, nothing)::Union{Nothing, Vector{Any}}
11041226
if entries !== nothing
11051227
for entry in entries
@@ -1531,7 +1653,7 @@ function insert_extension_triggers(env::String, pkg::PkgId)::Union{Nothing,Missi
15311653
project_file isa String || return nothing
15321654
manifest_file = project_file_manifest_path(project_file)
15331655
manifest_file === nothing && return
1534-
d = get_deps(parsed_toml(manifest_file))
1656+
d = get_deps(parsed_toml(manifest_file; manifest=true))
15351657
for (dep_name, entries) in d
15361658
entries::Vector{Any}
15371659
for entry in entries
@@ -2507,7 +2629,7 @@ function find_unsuitable_manifests_versions()
25072629
project_file isa String || continue # no project file
25082630
manifest_file = project_file_manifest_path(project_file)
25092631
manifest_file isa String || continue # no manifest file
2510-
m = parsed_toml(manifest_file)
2632+
m = parsed_toml(manifest_file; manifest=true)
25112633
man_julia_version = get(m, "julia_version", nothing)
25122634
man_julia_version isa String || @goto mark
25132635
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)