From e679ccca13def19b3cf31392fa0fe0a8af20faa3 Mon Sep 17 00:00:00 2001 From: Kristoffer Carlsson Date: Wed, 29 Oct 2025 13:47:52 +0100 Subject: [PATCH 1/4] 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. --- base/client.jl | 5 + base/initdefs.jl | 17 +- base/loading.jl | 177 +++++++++++++++--- base/precompilation.jl | 2 +- doc/src/manual/code-loading.md | 41 ++++ test/loading.jl | 43 +++++ test/project/portable_script.jl | 90 +++++++++ test/project/portable_script_custom.toml | 7 + .../portable_script_custom_manifest.jl | 34 ++++ test/project/portable_script_multiline.jl | 38 ++++ 10 files changed, 423 insertions(+), 31 deletions(-) create mode 100644 test/project/portable_script.jl create mode 100644 test/project/portable_script_custom.toml create mode 100644 test/project/portable_script_custom_manifest.jl create mode 100644 test/project/portable_script_multiline.jl diff --git a/base/client.jl b/base/client.jl index 1b62f870ae1b8..6980863af4e15 100644 --- a/base/client.jl +++ b/base/client.jl @@ -275,6 +275,11 @@ function exec_options(opts) # remove filename from ARGS global PROGRAM_FILE = arg_is_program ? popfirst!(ARGS) : "" + if arg_is_program && PROGRAM_FILE != "-" && Base.active_project(false) === nothing + script_path = abspath(PROGRAM_FILE) + Base.has_inline_project(script_path) && Base.set_active_project(script_path) + end + # Load Distributed module only if any of the Distributed options have been specified. distributed_mode = (opts.worker == 1) || (opts.nprocs > 0) || (opts.machine_file != C_NULL) if distributed_mode diff --git a/base/initdefs.jl b/base/initdefs.jl index 2c9ded9c4033e..86ac2e983d6fa 100644 --- a/base/initdefs.jl +++ b/base/initdefs.jl @@ -288,10 +288,16 @@ function load_path_expand(env::AbstractString)::Union{String, Nothing} program_file = program_file != C_NULL ? unsafe_string(program_file) : nothing isnothing(program_file) && return nothing # User did not pass a script + # Check if the program file itself is a portable script first + if env == "@script" && Base.has_inline_project(program_file) + return abspath(program_file) + end + # Expand trailing relative path dir = dirname(program_file) dir = env != "@script" ? (dir * env[length("@script")+1:end]) : dir - return current_project(dir) + project = current_project(dir) + return project end env = replace(env, '#' => VERSION.major, count=1) env = replace(env, '#' => VERSION.minor, count=1) @@ -326,7 +332,9 @@ load_path_expand(::Nothing) = nothing """ active_project() -Return the path of the active `Project.toml` file. See also [`Base.set_active_project`](@ref). +Return the path of the active project (either a `Project.toml` file or a julia +file when using a [portable script](@ref portable-scripts)). +See also [`Base.set_active_project`](@ref). """ function active_project(search_load_path::Bool=true) for project in (ACTIVE_PROJECT[],) @@ -355,7 +363,9 @@ end """ set_active_project(projfile::Union{AbstractString,Nothing}) -Set the active `Project.toml` file to `projfile`. See also [`Base.active_project`](@ref). +Set the active `Project.toml` file to `projfile`. The `projfile` can be a path to a traditional +`Project.toml` file, a [portable script](@ref portable-scripts) with inline metadata, or `nothing` +to clear the active project. See also [`Base.active_project`](@ref). !!! compat "Julia 1.8" This function requires at least Julia 1.8. @@ -376,6 +386,7 @@ end active_manifest(project_file::AbstractString) Return the path of the active manifest file, or the manifest file that would be used for a given `project_file`. +When a [portable script](@ref portable-scripts) is active, this returns the script path itself. In a stacked environment (where multiple environments exist in the load path), this returns the manifest file for the primary (active) environment only, not the manifests from other environments in the stack. diff --git a/base/loading.jl b/base/loading.jl index 1721969cfbf01..7154014f5e10e 100644 --- a/base/loading.jl +++ b/base/loading.jl @@ -212,22 +212,20 @@ mutable struct CachedTOMLDict size::Int64 hash::UInt32 d::Dict{String, Any} + kind::Symbol # :full (regular TOML), :project, :manifest (inline) end -function CachedTOMLDict(p::TOML.Parser, path::String) +function CachedTOMLDict(p::TOML.Parser, path::String, kind) s = stat(path) - content = read(path) + content = if kind === :full + String(read(path)) + else + String(extract_inline_section(path, kind)) + end crc32 = _crc32c(content) - TOML.reinit!(p, String(content); filepath=path) + TOML.reinit!(p, content; filepath=path) d = TOML.parse(p) - return CachedTOMLDict( - path, - s.inode, - s.mtime, - s.size, - crc32, - d, - ) + return CachedTOMLDict(path, s.inode, s.mtime, s.size, crc32, d, kind) end function get_updated_dict(p::TOML.Parser, f::CachedTOMLDict) @@ -236,20 +234,126 @@ function get_updated_dict(p::TOML.Parser, f::CachedTOMLDict) # identical but that is solvable by not doing in-place updates, and not # rapidly changing these files if s.inode != f.inode || s.mtime != f.mtime || f.size != s.size - content = read(f.path) - new_hash = _crc32c(content) + file_content = read(f.path) + new_hash = _crc32c(file_content) if new_hash != f.hash f.inode = s.inode f.mtime = s.mtime f.size = s.size f.hash = new_hash - TOML.reinit!(p, String(content); filepath=f.path) + + # Extract the appropriate TOML content based on kind + toml_content = if f.kind == :full + String(file_content) + else + String(extract_inline_section(f.path, f.kind)) + end + + TOML.reinit!(p, toml_content; filepath=f.path) return f.d = TOML.parse(p) end end return f.d end + +function extract_inline_section(path::String, type::Symbol) + buf = IOBuffer() + start_fence = "#!$type begin" + end_fence = "#!$type end" + state = :none + multiline_mode = false + in_multiline = false + + for (lineno, line) in enumerate(eachline(path)) + stripped = lstrip(line) + state == :done && break + + if startswith(stripped, start_fence) + state = :reading_first + continue + elseif startswith(stripped, end_fence) + state = :done + continue + elseif state === :reading_first + # First line determines the format + if startswith(stripped, "#=") + multiline_mode = true + state = :reading + # Check if the opening #= and closing =# are on the same line + if endswith(rstrip(stripped), "=#") + # Single-line multi-line comment + content = rstrip(stripped)[3:end-2] + write(buf, content) + in_multiline = false + else + # Multi-line comment continues + in_multiline = true + content = stripped[3:end] # Remove #= from start + write(buf, content) + write(buf, '\n') + end + else + # Line-by-line format + multiline_mode = false + state = :reading + # Process this first line + if startswith(stripped, '#') + toml_line = lstrip(chop(stripped, head=1, tail=0)) + write(buf, toml_line) + else + write(buf, line) + end + write(buf, '\n') + end + elseif state === :reading + if multiline_mode && in_multiline + # In multi-line comment mode, look for closing =# + if endswith(rstrip(stripped), "=#") + # Found closing delimiter + content = rstrip(stripped)[1:end-2] # Remove =# from end + write(buf, content) + in_multiline = false + else + # Still inside multi-line comment + write(buf, line) + write(buf, '\n') + end + elseif !multiline_mode + # Line-by-line comment mode, strip # from each line + if startswith(stripped, '#') + toml_line = lstrip(chop(stripped, head=1, tail=0)) + write(buf, toml_line) + else + write(buf, line) + end + write(buf, '\n') + end + # If multiline_mode && !in_multiline, the multiline comment has ended. + # Don't accumulate any more content; just wait for the end fence. + end + end + + if state === :done + return strip(String(take!(buf))) + elseif state === :none + return "" + else + error("incomplete inline $type block in $path (missing #!$type end)") + end +end + +function has_inline_project(path::String)::Bool + for line in eachline(path) + stripped = lstrip(line) + if startswith(stripped, "#!project begin") + return true + end + end + return false +end + + struct LoadingCache load_path::Vector{String} dummy_uuid::Dict{String, UUID} @@ -273,26 +377,38 @@ TOMLCache(p::TOML.Parser, d::Dict{String, Dict{String, Any}}) = TOMLCache(p, con const TOML_CACHE = TOMLCache(TOML.Parser{nothing}()) -parsed_toml(project_file::AbstractString) = parsed_toml(project_file, TOML_CACHE, require_lock) -function parsed_toml(project_file::AbstractString, toml_cache::TOMLCache, toml_lock::ReentrantLock) +parsed_toml(toml_file::AbstractString; manifest::Bool=false, project::Bool=!manifest) = + parsed_toml(toml_file, TOML_CACHE, require_lock; manifest=manifest, project=project) +function parsed_toml(toml_file::AbstractString, toml_cache::TOMLCache, toml_lock::ReentrantLock; + manifest::Bool=false, project::Bool=!manifest) + manifest && project && throw(ArgumentError("cannot request both project and manifest TOML")) lock(toml_lock) do + # Portable script? + if endswith(toml_file, ".jl") && isfile_casesensitive(toml_file) + kind = manifest ? :manifest : :project + cache_key = "$(toml_file)::$(kind)" + else + kind = :full + cache_key = toml_file + end + cache = LOADING_CACHE[] - dd = if !haskey(toml_cache.d, project_file) - d = CachedTOMLDict(toml_cache.p, project_file) - toml_cache.d[project_file] = d + dd = if !haskey(toml_cache.d, cache_key) + d = CachedTOMLDict(toml_cache.p, toml_file, kind) + toml_cache.d[cache_key] = d d.d else - d = toml_cache.d[project_file] + d = toml_cache.d[cache_key] # We are in a require call and have already parsed this TOML file # assume that it is unchanged to avoid hitting disk - if cache !== nothing && project_file in cache.require_parsed + if cache !== nothing && cache_key in cache.require_parsed d.d else get_updated_dict(toml_cache.p, d) end end if cache !== nothing - push!(cache.require_parsed, project_file) + push!(cache.require_parsed, cache_key) end return dd end @@ -656,6 +772,8 @@ function env_project_file(env::String)::Union{Bool,String} project_file = locate_project_file(env) elseif basename(env) in project_names && isfile_casesensitive(env) project_file = env + elseif endswith(env, ".jl") && isfile_casesensitive(env) + project_file = has_inline_project(env) ? env : false else project_file = false end @@ -897,7 +1015,8 @@ function project_file_manifest_path(project_file::String)::Union{Nothing,String} manifest_path === missing || return manifest_path end dir = abspath(dirname(project_file)) - isfile_casesensitive(project_file) || return nothing + has_file = isfile_casesensitive(project_file) + has_file || return nothing d = parsed_toml(project_file) base_manifest = workspace_manifest(project_file) if base_manifest !== nothing @@ -911,6 +1030,10 @@ function project_file_manifest_path(project_file::String)::Union{Nothing,String} manifest_path = manifest_file end end + if manifest_path === nothing && endswith(project_file, ".jl") && has_file + # portable script: manifest is the same file as the project file + manifest_path = project_file + end if manifest_path === nothing for mfst in manifest_names manifest_file = joinpath(dir, mfst) @@ -1038,7 +1161,7 @@ dep_stanza_get(stanza::Nothing, name::String) = nothing function explicit_manifest_deps_get(project_file::String, where::PkgId, name::String)::Union{Nothing,PkgId} manifest_file = project_file_manifest_path(project_file) manifest_file === nothing && return nothing # manifest not found--keep searching LOAD_PATH - d = get_deps(parsed_toml(manifest_file)) + d = get_deps(parsed_toml(manifest_file; manifest=true)) for (dep_name, entries) in d entries::Vector{Any} for entry in entries @@ -1111,7 +1234,7 @@ function explicit_manifest_uuid_path(project_file::String, pkg::PkgId)::Union{No manifest_file = project_file_manifest_path(project_file) manifest_file === nothing && return nothing # no manifest, skip env - d = get_deps(parsed_toml(manifest_file)) + d = get_deps(parsed_toml(manifest_file; manifest=true)) entries = get(d, pkg.name, nothing)::Union{Nothing, Vector{Any}} if entries !== nothing for entry in entries @@ -1540,7 +1663,7 @@ function insert_extension_triggers(env::String, pkg::PkgId)::Union{Nothing,Missi project_file isa String || return nothing manifest_file = project_file_manifest_path(project_file) manifest_file === nothing && return - d = get_deps(parsed_toml(manifest_file)) + d = get_deps(parsed_toml(manifest_file; manifest=true)) for (dep_name, entries) in d entries::Vector{Any} for entry in entries @@ -2516,7 +2639,7 @@ function find_unsuitable_manifests_versions() project_file isa String || continue # no project file manifest_file = project_file_manifest_path(project_file) manifest_file isa String || continue # no manifest file - m = parsed_toml(manifest_file) + m = parsed_toml(manifest_file; manifest=true) man_julia_version = get(m, "julia_version", nothing) man_julia_version isa String || @goto mark man_julia_version = VersionNumber(man_julia_version) diff --git a/base/precompilation.jl b/base/precompilation.jl index adecc50f6d1e4..82cc966baaa9b 100644 --- a/base/precompilation.jl +++ b/base/precompilation.jl @@ -108,7 +108,7 @@ function ExplicitEnv(envpath::String) end manifest = project_file_manifest_path(envpath) - manifest_d = manifest === nothing ? Dict{String, Any}() : parsed_toml(manifest) + manifest_d = manifest === nothing ? Dict{String, Any}() : parsed_toml(manifest; manifest=true) # Dependencies in a manifest can either be stored compressed (when name is unique among all packages) # in which case it is a `Vector{String}` or expanded where it is a `name => uuid` mapping. diff --git a/doc/src/manual/code-loading.md b/doc/src/manual/code-loading.md index 5871530720d22..655f81b0c1f96 100644 --- a/doc/src/manual/code-loading.md +++ b/doc/src/manual/code-loading.md @@ -397,6 +397,47 @@ are stored in the manifest file in the section for that package. The dependency a package are the same as for its "parent" except that the listed triggers are also considered as dependencies. +### [Portable scripts](@id portable-scripts) + +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`: + +```julia +#!project begin +# name = "HelloApp" +# uuid = "9c5fa7d8-7220-48e8-b2f7-0042191c5f6d" +# version = "0.1.0" +# [deps] +# Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a" +#!project end + +#!manifest begin +# [[deps]] +# name = "Markdown" +# uuid = "d6f4376e-aef5-505a-96c1-9c027394607a" +# version = "1.0.0" +#!manifest end + +using Markdown +println(md"# Hello, single-file world!") +``` + +Lines inside the fenced blocks may either start with `#` (as in the example), be plain TOML, or be wrapped in multi-line comment delimiters `#= ... =#`: + +```julia +#!project begin +#= +name = "HelloApp" +uuid = "9c5fa7d8-7220-48e8-b2f7-0042191c5f6d" +version = "0.1.0" +[deps] +Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a" +=# +#!project end +``` + + +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. + ### [Workspaces](@id workspaces) A project file can define a workspace by giving a set of projects that is part of that workspace: diff --git a/test/loading.jl b/test/loading.jl index ad5eab3768760..8ac65a28f8cc4 100644 --- a/test/loading.jl +++ b/test/loading.jl @@ -1870,3 +1870,46 @@ end module M58272_to end @eval M58272_to import ..M58272_1: M58272_2.y, x @test @eval M58272_to x === 1 + +@testset "Portable scripts" begin + # Test with line-by-line comment syntax and path dependencies + portable_script = joinpath(@__DIR__, "project", "portable_script.jl") + output = read(`$(Base.julia_cmd()) --startup-file=no $portable_script`, String) + + @test occursin("Active project: $portable_script", output) + @test occursin("Active manifest: $portable_script", output) + @test occursin("✓ Random (stdlib) loaded successfully", output) + @test occursin("✓ Rot13 (path dependency) loaded successfully", output) + @test occursin("✓ Rot13 methods available", output) + @test occursin("Test Summary:", output) + @test occursin("Portable Script Tests", output) + @test occursin("Pass", output) + + # Test with multiline comment syntax + portable_script_ml = joinpath(@__DIR__, "project", "portable_script_multiline.jl") + output_ml = read(`$(Base.julia_cmd()) --startup-file=no $portable_script_ml`, String) + + @test occursin("Active project: $portable_script_ml", output_ml) + @test occursin("Active manifest: $portable_script_ml", output_ml) + @test occursin("✓ Portable script with multiline comment syntax works!", output_ml) + @test occursin("✓ Random.rand()", output_ml) + @test occursin("✓ All checks passed!", output_ml) + + # Test with custom manifest= entry in project section + portable_script_cm = joinpath(@__DIR__, "project", "portable_script_custom_manifest.jl") + output_cm = read(`$(Base.julia_cmd()) --startup-file=no $portable_script_cm`, String) + expected_cm = joinpath(@__DIR__, "project", "portable_script_custom.toml") + + @test occursin("Active project: $portable_script_cm", output_cm) + @test occursin("Active manifest: $expected_cm", output_cm) + @test occursin("✓ Custom manifest file is being used: $expected_cm", output_cm) + @test occursin("✓ Random.rand()", output_cm) + @test occursin("✓ All checks passed!", output_cm) + + # Test @script behavior with portable script + # When using --project=@script, it should use the script file as the project + output_script = read(`$(Base.julia_cmd()) --startup-file=no --project=@script $portable_script`, String) + @test occursin("Active project: $portable_script", output_script) + @test occursin("Active manifest: $portable_script", output_script) + @test occursin("✓ Random (stdlib) loaded successfully", output_script) +end diff --git a/test/project/portable_script.jl b/test/project/portable_script.jl new file mode 100644 index 0000000000000..7a52f6be98110 --- /dev/null +++ b/test/project/portable_script.jl @@ -0,0 +1,90 @@ +#!/usr/bin/env julia + +#!project begin +# name = "PortableScriptTest" +# uuid = "f7e12c4d-9a2b-4c3f-8e5d-6a7b8c9d0e1f" +# version = "0.1.0" +# [deps] +# Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +# Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +# Rot13 = "43ef800a-eac4-47f4-949b-25107b932e8f" +#!project end + +#!manifest begin +# julia_version = "1.13.0" +# manifest_format = "2.0" +# project_hash = "abc123" +# +# [[deps.Random]] +# uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +# version = "1.11.0" +# +# [[deps.Test]] +# deps = ["InteractiveUtils", "Logging", "Random", "Serialization"] +# uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +# version = "1.11.0" +# +# [[deps.InteractiveUtils]] +# deps = ["Markdown"] +# uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240" +# version = "1.11.0" +# +# [[deps.Logging]] +# uuid = "56ddb016-857b-54e1-b83d-db4d58db5568" +# version = "1.11.0" +# +# [[deps.Serialization]] +# uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" +# version = "1.11.0" +# +# [[deps.Markdown]] +# deps = ["Base64", "JuliaSyntaxHighlighting", "StyledStrings"] +# uuid = "d6f4376e-aef5-505a-96c1-9c027394607a" +# version = "1.11.0" +# +# [[deps.Base64]] +# uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" +# version = "1.11.0" +# +# [[deps.JuliaSyntaxHighlighting]] +# deps = ["StyledStrings"] +# uuid = "ac6e5ff7-fb65-4e79-a425-ec3bc9c03011" +# version = "1.12.0" +# +# [[deps.StyledStrings]] +# uuid = "f489334b-da3d-4c2e-b8f0-e476e12c162b" +# version = "1.11.0" +# +# [[deps.Rot13]] +# path = "Rot13" +# uuid = "43ef800a-eac4-47f4-949b-25107b932e8f" +# version = "0.1.0" +#!manifest end + +using Random +using Test +using Rot13 + +# Verify the portable script environment is active +println("Active project: ", Base.active_project()) +println("Active manifest: ", Base.active_manifest()) +println() + +# Test that stdlib packages work +@testset "Portable Script Tests" begin + # Test Random (stdlib) + Random.seed!(42) + r = rand() + @test 0 <= r <= 1 + println("✓ Random (stdlib) loaded successfully") + + # Test Rot13 (path-based dependency) + @test Rot13.rot13("Hello") == "Uryyb" + @test Rot13.rot13("World") == "Jbeyq" + println("✓ Rot13 (path dependency) loaded successfully") + + # Test that Rot13 module has expected functions + @test hasmethod(Rot13.rot13, (Char,)) + @test hasmethod(Rot13.rot13, (AbstractString,)) + println("✓ Rot13 methods available") +end diff --git a/test/project/portable_script_custom.toml b/test/project/portable_script_custom.toml new file mode 100644 index 0000000000000..cdfd44243742c --- /dev/null +++ b/test/project/portable_script_custom.toml @@ -0,0 +1,7 @@ +julia_version = "1.13.0" +manifest_format = "2.0" +project_hash = "custom_manifest_test" + +[[deps.Random]] +uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +version = "1.11.0" diff --git a/test/project/portable_script_custom_manifest.jl b/test/project/portable_script_custom_manifest.jl new file mode 100644 index 0000000000000..17c55b327f7d8 --- /dev/null +++ b/test/project/portable_script_custom_manifest.jl @@ -0,0 +1,34 @@ +#!/usr/bin/env julia + +#!project begin +# name = "PortableScriptCustomManifestTest" +# uuid = "1a2b3c4d-5e6f-7890-abcd-ef1234567890" +# version = "0.1.0" +# manifest = "portable_script_custom.toml" +# [deps] +# Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +#!project end + +using Random + +# Test that the custom manifest is being used +println("Active project: ", Base.active_project()) +println("Active manifest: ", Base.active_manifest()) + +# Verify that the active manifest points to our custom manifest file +expected_manifest = joinpath(dirname(Base.active_project()), "portable_script_custom.toml") +actual_manifest = Base.active_manifest() + +if actual_manifest == expected_manifest + println("✓ Custom manifest file is being used: $actual_manifest") +else + error("Expected manifest: $expected_manifest, but got: $actual_manifest") +end + +# Test that Random works +Random.seed!(456) +x = rand() +@assert 0 <= x <= 1 +println("✓ Random.rand() = ", x) + +println("✓ All checks passed!") diff --git a/test/project/portable_script_multiline.jl b/test/project/portable_script_multiline.jl new file mode 100644 index 0000000000000..e4680fc242c56 --- /dev/null +++ b/test/project/portable_script_multiline.jl @@ -0,0 +1,38 @@ +#!/usr/bin/env julia + +#!project begin +#= +name = "PortableScriptMultilineTest" +uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" +version = "0.1.0" + +[deps] +Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +=# +#!project end + +#!manifest begin +#= +julia_version = "1.13.0" +manifest_format = "2.0" +project_hash = "xyz789" + +[[deps.Random]] +uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +version = "1.11.0" +=# +#!manifest end + +using Random + +println("✓ Portable script with multiline comment syntax works!") +println("Active project: ", Base.active_project()) +println("Active manifest: ", Base.active_manifest()) + +# Simple test +Random.seed!(123) +x = rand() +println("✓ Random.rand() = ", x) +@assert 0 <= x <= 1 + +println("✓ All checks passed!") From a21aabf9391eb790c60970c395ff38a294c53e33 Mon Sep 17 00:00:00 2001 From: Kristoffer Carlsson Date: Mon, 3 Nov 2025 09:23:05 +0100 Subject: [PATCH 2/4] remove multiline comment support, add requirement that project has to be first, manifest last --- base/loading.jl | 126 ++++++++++------------ doc/src/manual/code-loading.md | 21 +--- test/loading.jl | 53 +++++++-- test/project/invalid_both.jl | 11 ++ test/project/invalid_code_between.jl | 14 +++ test/project/invalid_manifest_not_last.jl | 12 +++ test/project/invalid_project_not_first.jl | 10 ++ test/project/portable_script.jl | 57 +++++----- test/project/portable_script_multiline.jl | 38 ------- test/project/regular_script.jl | 5 + 10 files changed, 187 insertions(+), 160 deletions(-) create mode 100644 test/project/invalid_both.jl create mode 100644 test/project/invalid_code_between.jl create mode 100644 test/project/invalid_manifest_not_last.jl create mode 100644 test/project/invalid_project_not_first.jl delete mode 100644 test/project/portable_script_multiline.jl create mode 100644 test/project/regular_script.jl diff --git a/base/loading.jl b/base/loading.jl index 7154014f5e10e..54ad1e3a36cdf 100644 --- a/base/loading.jl +++ b/base/loading.jl @@ -258,88 +258,80 @@ end function extract_inline_section(path::String, type::Symbol) - buf = IOBuffer() - start_fence = "#!$type begin" - end_fence = "#!$type end" + # Read all lines + lines = readlines(path) + + # For manifest, read backwards by reversing the lines + if type === :manifest + lines = reverse(lines) + start_marker = "#!manifest end" + end_marker = "#!manifest begin" + section_name = "manifest" + position_error = "must come last" + else + start_marker = "#!project begin" + end_marker = "#!project end" + section_name = "project" + position_error = "must come first" + end + state = :none - multiline_mode = false - in_multiline = false + at_start = true + content_lines = String[] - for (lineno, line) in enumerate(eachline(path)) + for (lineno, line) in enumerate(lines) stripped = lstrip(line) - state == :done && break - if startswith(stripped, start_fence) - state = :reading_first + # Skip empty lines and comments (including shebang) before content + if at_start && (isempty(stripped) || startswith(stripped, '#')) + if startswith(stripped, start_marker) + state = :reading + at_start = false + continue + end continue - elseif startswith(stripped, end_fence) - state = :done + end + + # Found start marker after content - error + if startswith(stripped, start_marker) + if !at_start + error("#!$section_name section $position_error in $path") + end + state = :reading + at_start = false continue - elseif state === :reading_first - # First line determines the format - if startswith(stripped, "#=") - multiline_mode = true - state = :reading - # Check if the opening #= and closing =# are on the same line - if endswith(rstrip(stripped), "=#") - # Single-line multi-line comment - content = rstrip(stripped)[3:end-2] - write(buf, content) - in_multiline = false - else - # Multi-line comment continues - in_multiline = true - content = stripped[3:end] # Remove #= from start - write(buf, content) - write(buf, '\n') - end + end + + at_start = false + + # Found end marker + if startswith(stripped, end_marker) && state === :reading + state = :done + break + end + + # Extract content + if state === :reading + if startswith(stripped, '#') + toml_line = lstrip(chop(stripped, head=1, tail=0)) + push!(content_lines, toml_line) else - # Line-by-line format - multiline_mode = false - state = :reading - # Process this first line - if startswith(stripped, '#') - toml_line = lstrip(chop(stripped, head=1, tail=0)) - write(buf, toml_line) - else - write(buf, line) - end - write(buf, '\n') + push!(content_lines, line) end - elseif state === :reading - if multiline_mode && in_multiline - # In multi-line comment mode, look for closing =# - if endswith(rstrip(stripped), "=#") - # Found closing delimiter - content = rstrip(stripped)[1:end-2] # Remove =# from end - write(buf, content) - in_multiline = false - else - # Still inside multi-line comment - write(buf, line) - write(buf, '\n') - end - elseif !multiline_mode - # Line-by-line comment mode, strip # from each line - if startswith(stripped, '#') - toml_line = lstrip(chop(stripped, head=1, tail=0)) - write(buf, toml_line) - else - write(buf, line) - end - write(buf, '\n') - end - # If multiline_mode && !in_multiline, the multiline comment has ended. - # Don't accumulate any more content; just wait for the end fence. end end + # For manifest, reverse the content back to original order + if type === :manifest && !isempty(content_lines) + content_lines = reverse(content_lines) + end + if state === :done - return strip(String(take!(buf))) + return strip(join(content_lines, '\n')) elseif state === :none return "" else - error("incomplete inline $type block in $path (missing #!$type end)") + error("incomplete inline $section_name block in $path (missing #!$section_name end)") end end diff --git a/doc/src/manual/code-loading.md b/doc/src/manual/code-loading.md index 655f81b0c1f96..b0ef7e304ef37 100644 --- a/doc/src/manual/code-loading.md +++ b/doc/src/manual/code-loading.md @@ -410,31 +410,18 @@ Julia also understands *portable scripts*: scripts that embed their own `Project # Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a" #!project end +using Markdown +println(md"# Hello, single-file world!") + #!manifest begin # [[deps]] # name = "Markdown" # uuid = "d6f4376e-aef5-505a-96c1-9c027394607a" # version = "1.0.0" #!manifest end - -using Markdown -println(md"# Hello, single-file world!") -``` - -Lines inside the fenced blocks may either start with `#` (as in the example), be plain TOML, or be wrapped in multi-line comment delimiters `#= ... =#`: - -```julia -#!project begin -#= -name = "HelloApp" -uuid = "9c5fa7d8-7220-48e8-b2f7-0042191c5f6d" -version = "0.1.0" -[deps] -Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a" -=# -#!project end ``` +Lines inside the fenced blocks should be commented with `#` (as in the example) or be plain TOML lines. The `#!project` section must come first in the file (after an optional shebang and empty lines). If a `#!manifest` section is present, it must come after the `#!project` section, and no Julia code is allowed after the `#!manifest end` delimiter. 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. diff --git a/test/loading.jl b/test/loading.jl index 8ac65a28f8cc4..ecd4858f224a4 100644 --- a/test/loading.jl +++ b/test/loading.jl @@ -1885,16 +1885,6 @@ module M58272_to end @test occursin("Portable Script Tests", output) @test occursin("Pass", output) - # Test with multiline comment syntax - portable_script_ml = joinpath(@__DIR__, "project", "portable_script_multiline.jl") - output_ml = read(`$(Base.julia_cmd()) --startup-file=no $portable_script_ml`, String) - - @test occursin("Active project: $portable_script_ml", output_ml) - @test occursin("Active manifest: $portable_script_ml", output_ml) - @test occursin("✓ Portable script with multiline comment syntax works!", output_ml) - @test occursin("✓ Random.rand()", output_ml) - @test occursin("✓ All checks passed!", output_ml) - # Test with custom manifest= entry in project section portable_script_cm = joinpath(@__DIR__, "project", "portable_script_custom_manifest.jl") output_cm = read(`$(Base.julia_cmd()) --startup-file=no $portable_script_cm`, String) @@ -1912,4 +1902,47 @@ module M58272_to end @test occursin("Active project: $portable_script", output_script) @test occursin("Active manifest: $portable_script", output_script) @test occursin("✓ Random (stdlib) loaded successfully", output_script) + + # Test that regular Julia files (without inline sections) work fine as projects + regular_script = joinpath(@__DIR__, "project", "regular_script.jl") + + # Running the script with --project= should set it as active project + output = read(`$(Base.julia_cmd()) --startup-file=no --project=$regular_script $regular_script`, String) + @test occursin("ACTIVE_PROJECT: $regular_script", output) + @test occursin("Hello from regular script", output) + @test occursin("x = 42", output) + + # Running the script without --project should NOT set it as active project + output = read(`$(Base.julia_cmd()) --startup-file=no $regular_script`, String) + @test !occursin("ACTIVE_PROJECT: $regular_script", output) + @test occursin("Hello from regular script", output) + @test occursin("x = 42", output) + + # Test 1: Project section not first (has code before it) + invalid_project_not_first = joinpath(@__DIR__, "project", "invalid_project_not_first.jl") + err_output = IOBuffer() + result = run(pipeline(ignorestatus(`$(Base.julia_cmd()) --startup-file=no $invalid_project_not_first`), stderr=err_output)) + @test !success(result) + @test occursin("#!project section must come first", String(take!(err_output))) + + # Test 2: Manifest section not last (has code after it) + invalid_manifest_not_last = joinpath(@__DIR__, "project", "invalid_manifest_not_last.jl") + err_output = IOBuffer() + result = run(pipeline(ignorestatus(`$(Base.julia_cmd()) --startup-file=no $invalid_manifest_not_last`), stderr=err_output)) + @test !success(result) + @test occursin("#!manifest section must come last", String(take!(err_output))) + + # Test 3: Project not first, but manifest present + invalid_both = joinpath(@__DIR__, "project", "invalid_both.jl") + err_output = IOBuffer() + result = run(pipeline(ignorestatus(`$(Base.julia_cmd()) --startup-file=no --project=$invalid_both -e "using Test"`), stderr=err_output)) + @test !success(result) + @test occursin("#!project section must come first", String(take!(err_output))) + + # Test 4: Manifest with code in between sections + invalid_code_between = joinpath(@__DIR__, "project", "invalid_code_between.jl") + err_output = IOBuffer() + result = run(pipeline(ignorestatus(`$(Base.julia_cmd()) --startup-file=no --project=$invalid_code_between -e "using Test"`), stderr=err_output)) + @test !success(result) + @test occursin("#!manifest section must come last", String(take!(err_output))) end diff --git a/test/project/invalid_both.jl b/test/project/invalid_both.jl new file mode 100644 index 0000000000000..afaa50872ca52 --- /dev/null +++ b/test/project/invalid_both.jl @@ -0,0 +1,11 @@ +using Test + +function foo() + return 42 +end + +#!project begin +#!project end + +#!manifest begin +#!manifest end diff --git a/test/project/invalid_code_between.jl b/test/project/invalid_code_between.jl new file mode 100644 index 0000000000000..177312c934f88 --- /dev/null +++ b/test/project/invalid_code_between.jl @@ -0,0 +1,14 @@ +#!project begin +#!project end + +using Test + +# Some actual Julia code +function bar() + println("hello") +end + +#!manifest begin +#!manifest end + +bar() diff --git a/test/project/invalid_manifest_not_last.jl b/test/project/invalid_manifest_not_last.jl new file mode 100644 index 0000000000000..18cb0a93ce96f --- /dev/null +++ b/test/project/invalid_manifest_not_last.jl @@ -0,0 +1,12 @@ +#!project begin +# [deps] +# Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +#!project end + +using Test + +#!manifest begin +# julia_version = "1.11.0" +#!manifest end + +println("Code after manifest") diff --git a/test/project/invalid_project_not_first.jl b/test/project/invalid_project_not_first.jl new file mode 100644 index 0000000000000..a78d1d1beb3b1 --- /dev/null +++ b/test/project/invalid_project_not_first.jl @@ -0,0 +1,10 @@ +# Some code before project section + +using Test +x = 1 + +#!project begin +# name = "Test" +#!project end + +println("test") diff --git a/test/project/portable_script.jl b/test/project/portable_script.jl index 7a52f6be98110..521cddf1f8d02 100644 --- a/test/project/portable_script.jl +++ b/test/project/portable_script.jl @@ -10,6 +10,35 @@ # Rot13 = "43ef800a-eac4-47f4-949b-25107b932e8f" #!project end +using Random +using Test +using Rot13 + +# Verify the portable script environment is active +println("Active project: ", Base.active_project()) +println("Active manifest: ", Base.active_manifest()) +println() + +# Test that stdlib packages work +@testset "Portable Script Tests" begin + # Test Random (stdlib) + Random.seed!(42) + r = rand() + @test 0 <= r <= 1 + println("✓ Random (stdlib) loaded successfully") + + # Test Rot13 (path-based dependency) + @test Rot13.rot13("Hello") == "Uryyb" + @test Rot13.rot13("World") == "Jbeyq" + println("✓ Rot13 (path dependency) loaded successfully") + + # Test that Rot13 module has expected functions + @test hasmethod(Rot13.rot13, (Char,)) + @test hasmethod(Rot13.rot13, (AbstractString,)) + println("✓ Rot13 methods available") +end + + #!manifest begin # julia_version = "1.13.0" # manifest_format = "2.0" @@ -60,31 +89,3 @@ # uuid = "43ef800a-eac4-47f4-949b-25107b932e8f" # version = "0.1.0" #!manifest end - -using Random -using Test -using Rot13 - -# Verify the portable script environment is active -println("Active project: ", Base.active_project()) -println("Active manifest: ", Base.active_manifest()) -println() - -# Test that stdlib packages work -@testset "Portable Script Tests" begin - # Test Random (stdlib) - Random.seed!(42) - r = rand() - @test 0 <= r <= 1 - println("✓ Random (stdlib) loaded successfully") - - # Test Rot13 (path-based dependency) - @test Rot13.rot13("Hello") == "Uryyb" - @test Rot13.rot13("World") == "Jbeyq" - println("✓ Rot13 (path dependency) loaded successfully") - - # Test that Rot13 module has expected functions - @test hasmethod(Rot13.rot13, (Char,)) - @test hasmethod(Rot13.rot13, (AbstractString,)) - println("✓ Rot13 methods available") -end diff --git a/test/project/portable_script_multiline.jl b/test/project/portable_script_multiline.jl deleted file mode 100644 index e4680fc242c56..0000000000000 --- a/test/project/portable_script_multiline.jl +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env julia - -#!project begin -#= -name = "PortableScriptMultilineTest" -uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" -version = "0.1.0" - -[deps] -Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" -=# -#!project end - -#!manifest begin -#= -julia_version = "1.13.0" -manifest_format = "2.0" -project_hash = "xyz789" - -[[deps.Random]] -uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" -version = "1.11.0" -=# -#!manifest end - -using Random - -println("✓ Portable script with multiline comment syntax works!") -println("Active project: ", Base.active_project()) -println("Active manifest: ", Base.active_manifest()) - -# Simple test -Random.seed!(123) -x = rand() -println("✓ Random.rand() = ", x) -@assert 0 <= x <= 1 - -println("✓ All checks passed!") diff --git a/test/project/regular_script.jl b/test/project/regular_script.jl new file mode 100644 index 0000000000000..1b7213dd02d43 --- /dev/null +++ b/test/project/regular_script.jl @@ -0,0 +1,5 @@ +# This is a regular Julia file without any inline project/manifest sections +println("ACTIVE_PROJECT: ", Base.active_project()) +println("Hello from regular script") +x = 42 +println("x = ", x) From 33be86e565266ac780898023d26cbe8ec1c20acd Mon Sep 17 00:00:00 2001 From: KristofferC Date: Tue, 4 Nov 2025 17:23:05 +0100 Subject: [PATCH 3/4] slot into the package loading system for portable scripts --- base/client.jl | 12 ++++++++- base/loading.jl | 25 ++++++++++++++++++- test/loading.jl | 22 ++++++++++------ test/project/{ => portable}/invalid_both.jl | 2 -- .../{ => portable}/invalid_code_between.jl | 2 -- .../invalid_manifest_not_last.jl | 2 -- .../invalid_project_not_first.jl | 1 - .../project/{ => portable}/portable_script.jl | 2 +- .../portable_script_custom.toml | 1 - .../portable_script_custom_manifest.jl | 0 .../portable/portable_script_missing_dep.jl | 10 ++++++++ test/project/{ => portable}/regular_script.jl | 0 12 files changed, 60 insertions(+), 19 deletions(-) rename test/project/{ => portable}/invalid_both.jl (88%) rename test/project/{ => portable}/invalid_code_between.jl (91%) rename test/project/{ => portable}/invalid_manifest_not_last.jl (93%) rename test/project/{ => portable}/invalid_project_not_first.jl (90%) rename test/project/{ => portable}/portable_script.jl (99%) rename test/project/{ => portable}/portable_script_custom.toml (77%) rename test/project/{ => portable}/portable_script_custom_manifest.jl (100%) create mode 100644 test/project/portable/portable_script_missing_dep.jl rename test/project/{ => portable}/regular_script.jl (100%) diff --git a/base/client.jl b/base/client.jl index 6980863af4e15..0fa23a0681182 100644 --- a/base/client.jl +++ b/base/client.jl @@ -346,7 +346,17 @@ function exec_options(opts) if PROGRAM_FILE == "-" include_string(Main, read(stdin, String), "stdin") else - include(Main, PROGRAM_FILE) + abs_script_path = abspath(PROGRAM_FILE) + if has_inline_project(abs_script_path) + set_portable_script_state(abs_script_path) + try + include(Main, PROGRAM_FILE) + finally + global portable_script_state_global = nothing + end + else + include(Main, PROGRAM_FILE) + end end catch invokelatest(display_error, scrub_repl_backtrace(current_exceptions())) diff --git a/base/loading.jl b/base/loading.jl index 54ad1e3a36cdf..9aca4f36bdc4c 100644 --- a/base/loading.jl +++ b/base/loading.jl @@ -346,6 +346,24 @@ function has_inline_project(path::String)::Bool end +struct PortableScriptState + path::String + pkg::PkgId +end + +portable_script_state_global::Union{PortableScriptState, Nothing} = nothing + +function set_portable_script_state(abs_path::Union{Nothing, String}) + pkg = project_file_name_uuid(abs_path, splitext(basename(abs_path))[1]) + + # Verify the project and manifest delimiters: + parsed_toml(abs_path) + parsed_toml(abs_path; manifest=true) + + global portable_script_state_global = PortableScriptState(abs_path, pkg) +end + + struct LoadingCache load_path::Vector{String} dummy_uuid::Dict{String, UUID} @@ -440,7 +458,12 @@ end Same as [`Base.identify_package`](@ref) except that the path to the environment where the package is identified is also returned, except when the identity is not identified. """ -identify_package_env(where::Module, name::String) = identify_package_env(PkgId(where), name) +function identify_package_env(where::Module, name::String) + if where === Main && portable_script_state_global !== nothing + return identify_package_env(portable_script_state_global.pkg, name) + end + return identify_package_env(PkgId(where), name) +end function identify_package_env(where::Union{PkgId, Nothing}, name::String) # Special cases if where !== nothing diff --git a/test/loading.jl b/test/loading.jl index ecd4858f224a4..a4159202a3370 100644 --- a/test/loading.jl +++ b/test/loading.jl @@ -1873,7 +1873,7 @@ module M58272_to end @testset "Portable scripts" begin # Test with line-by-line comment syntax and path dependencies - portable_script = joinpath(@__DIR__, "project", "portable_script.jl") + portable_script = joinpath(@__DIR__, "project", "portable", "portable_script.jl") output = read(`$(Base.julia_cmd()) --startup-file=no $portable_script`, String) @test occursin("Active project: $portable_script", output) @@ -1886,9 +1886,9 @@ module M58272_to end @test occursin("Pass", output) # Test with custom manifest= entry in project section - portable_script_cm = joinpath(@__DIR__, "project", "portable_script_custom_manifest.jl") + portable_script_cm = joinpath(@__DIR__, "project", "portable", "portable_script_custom_manifest.jl") output_cm = read(`$(Base.julia_cmd()) --startup-file=no $portable_script_cm`, String) - expected_cm = joinpath(@__DIR__, "project", "portable_script_custom.toml") + expected_cm = joinpath(@__DIR__, "project", "portable", "portable_script_custom.toml") @test occursin("Active project: $portable_script_cm", output_cm) @test occursin("Active manifest: $expected_cm", output_cm) @@ -1904,7 +1904,7 @@ module M58272_to end @test occursin("✓ Random (stdlib) loaded successfully", output_script) # Test that regular Julia files (without inline sections) work fine as projects - regular_script = joinpath(@__DIR__, "project", "regular_script.jl") + regular_script = joinpath(@__DIR__, "project", "portable", "regular_script.jl") # Running the script with --project= should set it as active project output = read(`$(Base.julia_cmd()) --startup-file=no --project=$regular_script $regular_script`, String) @@ -1918,29 +1918,35 @@ module M58272_to end @test occursin("Hello from regular script", output) @test occursin("x = 42", output) + portable_script_missing = joinpath(@__DIR__, "project", "portable", "portable_script_missing_dep.jl") + err_output = IOBuffer() + result = run(pipeline(ignorestatus(`$(Base.julia_cmd()) --startup-file=no $portable_script_missing`), stderr=err_output)) + @test !success(result) + @test occursin("Package Rot13 not found in current path", String(take!(err_output))) + # Test 1: Project section not first (has code before it) - invalid_project_not_first = joinpath(@__DIR__, "project", "invalid_project_not_first.jl") + invalid_project_not_first = joinpath(@__DIR__, "project", "portable", "invalid_project_not_first.jl") err_output = IOBuffer() result = run(pipeline(ignorestatus(`$(Base.julia_cmd()) --startup-file=no $invalid_project_not_first`), stderr=err_output)) @test !success(result) @test occursin("#!project section must come first", String(take!(err_output))) # Test 2: Manifest section not last (has code after it) - invalid_manifest_not_last = joinpath(@__DIR__, "project", "invalid_manifest_not_last.jl") + invalid_manifest_not_last = joinpath(@__DIR__, "project", "portable", "invalid_manifest_not_last.jl") err_output = IOBuffer() result = run(pipeline(ignorestatus(`$(Base.julia_cmd()) --startup-file=no $invalid_manifest_not_last`), stderr=err_output)) @test !success(result) @test occursin("#!manifest section must come last", String(take!(err_output))) # Test 3: Project not first, but manifest present - invalid_both = joinpath(@__DIR__, "project", "invalid_both.jl") + invalid_both = joinpath(@__DIR__, "project", "portable", "invalid_both.jl") err_output = IOBuffer() result = run(pipeline(ignorestatus(`$(Base.julia_cmd()) --startup-file=no --project=$invalid_both -e "using Test"`), stderr=err_output)) @test !success(result) @test occursin("#!project section must come first", String(take!(err_output))) # Test 4: Manifest with code in between sections - invalid_code_between = joinpath(@__DIR__, "project", "invalid_code_between.jl") + invalid_code_between = joinpath(@__DIR__, "project", "portable", "invalid_code_between.jl") err_output = IOBuffer() result = run(pipeline(ignorestatus(`$(Base.julia_cmd()) --startup-file=no --project=$invalid_code_between -e "using Test"`), stderr=err_output)) @test !success(result) diff --git a/test/project/invalid_both.jl b/test/project/portable/invalid_both.jl similarity index 88% rename from test/project/invalid_both.jl rename to test/project/portable/invalid_both.jl index afaa50872ca52..8bcd3298f8120 100644 --- a/test/project/invalid_both.jl +++ b/test/project/portable/invalid_both.jl @@ -1,5 +1,3 @@ -using Test - function foo() return 42 end diff --git a/test/project/invalid_code_between.jl b/test/project/portable/invalid_code_between.jl similarity index 91% rename from test/project/invalid_code_between.jl rename to test/project/portable/invalid_code_between.jl index 177312c934f88..72f04961c4301 100644 --- a/test/project/invalid_code_between.jl +++ b/test/project/portable/invalid_code_between.jl @@ -1,8 +1,6 @@ #!project begin #!project end -using Test - # Some actual Julia code function bar() println("hello") diff --git a/test/project/invalid_manifest_not_last.jl b/test/project/portable/invalid_manifest_not_last.jl similarity index 93% rename from test/project/invalid_manifest_not_last.jl rename to test/project/portable/invalid_manifest_not_last.jl index 18cb0a93ce96f..0513adf51cdbe 100644 --- a/test/project/invalid_manifest_not_last.jl +++ b/test/project/portable/invalid_manifest_not_last.jl @@ -3,8 +3,6 @@ # Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" #!project end -using Test - #!manifest begin # julia_version = "1.11.0" #!manifest end diff --git a/test/project/invalid_project_not_first.jl b/test/project/portable/invalid_project_not_first.jl similarity index 90% rename from test/project/invalid_project_not_first.jl rename to test/project/portable/invalid_project_not_first.jl index a78d1d1beb3b1..97bd1df41dba4 100644 --- a/test/project/invalid_project_not_first.jl +++ b/test/project/portable/invalid_project_not_first.jl @@ -1,6 +1,5 @@ # Some code before project section -using Test x = 1 #!project begin diff --git a/test/project/portable_script.jl b/test/project/portable/portable_script.jl similarity index 99% rename from test/project/portable_script.jl rename to test/project/portable/portable_script.jl index 521cddf1f8d02..0dff1310da954 100644 --- a/test/project/portable_script.jl +++ b/test/project/portable/portable_script.jl @@ -85,7 +85,7 @@ end # version = "1.11.0" # # [[deps.Rot13]] -# path = "Rot13" +# path = "../Rot13" # uuid = "43ef800a-eac4-47f4-949b-25107b932e8f" # version = "0.1.0" #!manifest end diff --git a/test/project/portable_script_custom.toml b/test/project/portable/portable_script_custom.toml similarity index 77% rename from test/project/portable_script_custom.toml rename to test/project/portable/portable_script_custom.toml index cdfd44243742c..51b97d021e2f9 100644 --- a/test/project/portable_script_custom.toml +++ b/test/project/portable/portable_script_custom.toml @@ -1,6 +1,5 @@ julia_version = "1.13.0" manifest_format = "2.0" -project_hash = "custom_manifest_test" [[deps.Random]] uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" diff --git a/test/project/portable_script_custom_manifest.jl b/test/project/portable/portable_script_custom_manifest.jl similarity index 100% rename from test/project/portable_script_custom_manifest.jl rename to test/project/portable/portable_script_custom_manifest.jl diff --git a/test/project/portable/portable_script_missing_dep.jl b/test/project/portable/portable_script_missing_dep.jl new file mode 100644 index 0000000000000..5291711dfb48e --- /dev/null +++ b/test/project/portable/portable_script_missing_dep.jl @@ -0,0 +1,10 @@ +#!/usr/bin/env julia + +#!project begin +# name = "PortableScriptMissingDep" +# uuid = "2a4c4b38-9a08-4e35-9b2c-2d1f6890d5f7" +# version = "0.1.0" +# [deps] +#!project end + +using Rot13 diff --git a/test/project/regular_script.jl b/test/project/portable/regular_script.jl similarity index 100% rename from test/project/regular_script.jl rename to test/project/portable/regular_script.jl From 91bda203475c43d516ca8099297931ec26531dde Mon Sep 17 00:00:00 2001 From: KristofferC Date: Fri, 7 Nov 2025 18:15:08 +0100 Subject: [PATCH 4/4] use a `!# script` maker instead, avoid the term "portable" --- base/client.jl | 8 +-- base/initdefs.jl | 10 +-- base/loading.jl | 65 ++++++++--------- doc/src/manual/code-loading.md | 17 +++-- test/loading.jl | 72 +++++++++---------- .../{portable => scripts}/invalid_both.jl | 2 + .../invalid_manifest_not_last.jl | 12 ++-- .../invalid_project_not_first.jl | 4 +- .../{portable => scripts}/regular_script.jl | 0 .../portable_script.jl => scripts/script.jl} | 34 ++++----- .../script_custom.toml} | 0 .../script_custom_manifest.jl} | 5 +- .../script_missing_dep.jl} | 1 + .../valid_code_between.jl} | 2 + 14 files changed, 119 insertions(+), 113 deletions(-) rename test/project/{portable => scripts}/invalid_both.jl (90%) rename test/project/{portable => scripts}/invalid_manifest_not_last.jl (71%) rename test/project/{portable => scripts}/invalid_project_not_first.jl (61%) rename test/project/{portable => scripts}/regular_script.jl (100%) rename test/project/{portable/portable_script.jl => scripts/script.jl} (72%) rename test/project/{portable/portable_script_custom.toml => scripts/script_custom.toml} (100%) rename test/project/{portable/portable_script_custom_manifest.jl => scripts/script_custom_manifest.jl} (86%) rename test/project/{portable/portable_script_missing_dep.jl => scripts/script_missing_dep.jl} (95%) rename test/project/{portable/invalid_code_between.jl => scripts/valid_code_between.jl} (93%) diff --git a/base/client.jl b/base/client.jl index 0fa23a0681182..a5e72dceabe0a 100644 --- a/base/client.jl +++ b/base/client.jl @@ -277,7 +277,7 @@ function exec_options(opts) if arg_is_program && PROGRAM_FILE != "-" && Base.active_project(false) === nothing script_path = abspath(PROGRAM_FILE) - Base.has_inline_project(script_path) && Base.set_active_project(script_path) + Base.is_script(script_path) && Base.set_active_project(script_path) end # Load Distributed module only if any of the Distributed options have been specified. @@ -347,12 +347,12 @@ function exec_options(opts) include_string(Main, read(stdin, String), "stdin") else abs_script_path = abspath(PROGRAM_FILE) - if has_inline_project(abs_script_path) - set_portable_script_state(abs_script_path) + if is_script(abs_script_path) + set_script_state(abs_script_path) try include(Main, PROGRAM_FILE) finally - global portable_script_state_global = nothing + global script_state_global = nothing end else include(Main, PROGRAM_FILE) diff --git a/base/initdefs.jl b/base/initdefs.jl index 86ac2e983d6fa..109c9edb7fe61 100644 --- a/base/initdefs.jl +++ b/base/initdefs.jl @@ -288,8 +288,8 @@ function load_path_expand(env::AbstractString)::Union{String, Nothing} program_file = program_file != C_NULL ? unsafe_string(program_file) : nothing isnothing(program_file) && return nothing # User did not pass a script - # Check if the program file itself is a portable script first - if env == "@script" && Base.has_inline_project(program_file) + # Check if the program file itself is a script first + if env == "@script" && Base.is_script(program_file) return abspath(program_file) end @@ -333,7 +333,7 @@ load_path_expand(::Nothing) = nothing active_project() Return the path of the active project (either a `Project.toml` file or a julia -file when using a [portable script](@ref portable-scripts)). +file when using a [script](@ref scripts)). See also [`Base.set_active_project`](@ref). """ function active_project(search_load_path::Bool=true) @@ -364,7 +364,7 @@ end set_active_project(projfile::Union{AbstractString,Nothing}) Set the active `Project.toml` file to `projfile`. The `projfile` can be a path to a traditional -`Project.toml` file, a [portable script](@ref portable-scripts) with inline metadata, or `nothing` +`Project.toml` file, a [script](@ref scripts) with inline metadata, or `nothing` to clear the active project. See also [`Base.active_project`](@ref). !!! compat "Julia 1.8" @@ -386,7 +386,7 @@ end active_manifest(project_file::AbstractString) Return the path of the active manifest file, or the manifest file that would be used for a given `project_file`. -When a [portable script](@ref portable-scripts) is active, this returns the script path itself. +When a [script](@ref scripts) is active, this returns the script path itself. In a stacked environment (where multiple environments exist in the load path), this returns the manifest file for the primary (active) environment only, not the manifests from other environments in the stack. diff --git a/base/loading.jl b/base/loading.jl index 9aca4f36bdc4c..68074f2a359dd 100644 --- a/base/loading.jl +++ b/base/loading.jl @@ -261,49 +261,37 @@ function extract_inline_section(path::String, type::Symbol) # Read all lines lines = readlines(path) - # For manifest, read backwards by reversing the lines if type === :manifest - lines = reverse(lines) - start_marker = "#!manifest end" - end_marker = "#!manifest begin" + start_marker = "#!manifest begin" + end_marker = "#!manifest end" section_name = "manifest" - position_error = "must come last" else start_marker = "#!project begin" end_marker = "#!project end" section_name = "project" - position_error = "must come first" end state = :none - at_start = true content_lines = String[] + project_line = nothing + manifest_line = nothing for (lineno, line) in enumerate(lines) stripped = lstrip(line) - # Skip empty lines and comments (including shebang) before content - if at_start && (isempty(stripped) || startswith(stripped, '#')) - if startswith(stripped, start_marker) - state = :reading - at_start = false - continue - end - continue + # Track positions of sections for validation + if startswith(stripped, "#!project begin") + project_line = lineno + elseif startswith(stripped, "#!manifest begin") + manifest_line = lineno end - # Found start marker after content - error + # Found start marker if startswith(stripped, start_marker) - if !at_start - error("#!$section_name section $position_error in $path") - end state = :reading - at_start = false continue end - at_start = false - # Found end marker if startswith(stripped, end_marker) && state === :reading state = :done @@ -321,9 +309,9 @@ function extract_inline_section(path::String, type::Symbol) end end - # For manifest, reverse the content back to original order - if type === :manifest && !isempty(content_lines) - content_lines = reverse(content_lines) + # Validate that project comes before manifest + if project_line !== nothing && manifest_line !== nothing && project_line > manifest_line + error("#!manifest section must come after #!project section in $path") end if state === :done @@ -335,10 +323,14 @@ function extract_inline_section(path::String, type::Symbol) end end -function has_inline_project(path::String)::Bool +function is_script(path::String)::Bool for line in eachline(path) stripped = lstrip(line) - if startswith(stripped, "#!project begin") + # Only whitespace and comments allowed before #!script + if !isempty(stripped) && !startswith(stripped, '#') + return false + end + if startswith(stripped, "#!script") return true end end @@ -346,21 +338,21 @@ function has_inline_project(path::String)::Bool end -struct PortableScriptState +struct ScriptState path::String pkg::PkgId end -portable_script_state_global::Union{PortableScriptState, Nothing} = nothing +script_state_global::Union{ScriptState, Nothing} = nothing -function set_portable_script_state(abs_path::Union{Nothing, String}) +function set_script_state(abs_path::Union{Nothing, String}) pkg = project_file_name_uuid(abs_path, splitext(basename(abs_path))[1]) # Verify the project and manifest delimiters: parsed_toml(abs_path) parsed_toml(abs_path; manifest=true) - global portable_script_state_global = PortableScriptState(abs_path, pkg) + global script_state_global = ScriptState(abs_path, pkg) end @@ -393,7 +385,7 @@ function parsed_toml(toml_file::AbstractString, toml_cache::TOMLCache, toml_lock manifest::Bool=false, project::Bool=!manifest) manifest && project && throw(ArgumentError("cannot request both project and manifest TOML")) lock(toml_lock) do - # Portable script? + # Script? if endswith(toml_file, ".jl") && isfile_casesensitive(toml_file) kind = manifest ? :manifest : :project cache_key = "$(toml_file)::$(kind)" @@ -459,8 +451,8 @@ Same as [`Base.identify_package`](@ref) except that the path to the environment is also returned, except when the identity is not identified. """ function identify_package_env(where::Module, name::String) - if where === Main && portable_script_state_global !== nothing - return identify_package_env(portable_script_state_global.pkg, name) + if where === Main && script_state_global !== nothing + return identify_package_env(script_state_global.pkg, name) end return identify_package_env(PkgId(where), name) end @@ -788,7 +780,7 @@ function env_project_file(env::String)::Union{Bool,String} elseif basename(env) in project_names && isfile_casesensitive(env) project_file = env elseif endswith(env, ".jl") && isfile_casesensitive(env) - project_file = has_inline_project(env) ? env : false + project_file = is_script(env) ? env : false else project_file = false end @@ -1046,7 +1038,7 @@ function project_file_manifest_path(project_file::String)::Union{Nothing,String} end end if manifest_path === nothing && endswith(project_file, ".jl") && has_file - # portable script: manifest is the same file as the project file + # script: manifest is the same file as the project file manifest_path = project_file end if manifest_path === nothing @@ -1096,6 +1088,7 @@ end # Find the project file for the extension `ext` in the implicit env `dir`` function implicit_env_project_file_extension(dir::String, ext::PkgId) for pkg in readdir(dir; join=true) + isdir(pkg) || continue project_file = env_project_file(pkg) project_file isa String || continue path = project_file_ext_path(project_file, ext) diff --git a/doc/src/manual/code-loading.md b/doc/src/manual/code-loading.md index b0ef7e304ef37..e6a0b20651fc6 100644 --- a/doc/src/manual/code-loading.md +++ b/doc/src/manual/code-loading.md @@ -397,11 +397,17 @@ are stored in the manifest file in the section for that package. The dependency a package are the same as for its "parent" except that the listed triggers are also considered as dependencies. -### [Portable scripts](@id portable-scripts) +### [Scripts](@id scripts) -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`: +Julia also understands *scripts* that can embed their own `Project.toml` (and optionally `Manifest.toml`) so they can be executed as self-contained environments. A script is identified by a `#!script` marker at the top of the file (only whitespace and comments may appear before it). The embedded project and manifest data are placed inside comment fences named `#!project` and `#!manifest`: ```julia +#!/usr/bin/env julia +#!script + +using Markdown +println(md"# Hello, single-file world!") + #!project begin # name = "HelloApp" # uuid = "9c5fa7d8-7220-48e8-b2f7-0042191c5f6d" @@ -410,9 +416,6 @@ Julia also understands *portable scripts*: scripts that embed their own `Project # Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a" #!project end -using Markdown -println(md"# Hello, single-file world!") - #!manifest begin # [[deps]] # name = "Markdown" @@ -421,9 +424,9 @@ println(md"# Hello, single-file world!") #!manifest end ``` -Lines inside the fenced blocks should be commented with `#` (as in the example) or be plain TOML lines. The `#!project` section must come first in the file (after an optional shebang and empty lines). If a `#!manifest` section is present, it must come after the `#!project` section, and no Julia code is allowed after the `#!manifest end` delimiter. +Lines inside the fenced blocks should be commented with `#` (as in the example) or be plain TOML lines. The `#!script` marker must appear at the top of the file, with only whitespace and comments (including an optional shebang) allowed before it. The `#!project` and `#!manifest` sections can appear anywhere in the file (by convention at the bottom), but `#!project` must come before `#!manifest` if both are present. -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. +Running `julia hello.jl` automatically activates the embedded project if the file contains a `#!script` marker. The dependency loading rules for such a script is the same as for a package with the same project and manifest file. The `--project=@script` flag also expands to the script itself if the `#!script` marker is present. Using `--project=script.jl` explicitly requires that the script contains the `#!script` marker. ### [Workspaces](@id workspaces) diff --git a/test/loading.jl b/test/loading.jl index a4159202a3370..3fad0e9e15751 100644 --- a/test/loading.jl +++ b/test/loading.jl @@ -1871,40 +1871,37 @@ module M58272_to end @eval M58272_to import ..M58272_1: M58272_2.y, x @test @eval M58272_to x === 1 -@testset "Portable scripts" begin +@testset "Scripts" begin # Test with line-by-line comment syntax and path dependencies - portable_script = joinpath(@__DIR__, "project", "portable", "portable_script.jl") - output = read(`$(Base.julia_cmd()) --startup-file=no $portable_script`, String) + script = joinpath(@__DIR__, "project", "scripts", "script.jl") + output = read(`$(Base.julia_cmd()) --startup-file=no $script`, String) - @test occursin("Active project: $portable_script", output) - @test occursin("Active manifest: $portable_script", output) + @test occursin("Active project: $script", output) + @test occursin("Active manifest: $script", output) @test occursin("✓ Random (stdlib) loaded successfully", output) @test occursin("✓ Rot13 (path dependency) loaded successfully", output) @test occursin("✓ Rot13 methods available", output) - @test occursin("Test Summary:", output) - @test occursin("Portable Script Tests", output) - @test occursin("Pass", output) # Test with custom manifest= entry in project section - portable_script_cm = joinpath(@__DIR__, "project", "portable", "portable_script_custom_manifest.jl") - output_cm = read(`$(Base.julia_cmd()) --startup-file=no $portable_script_cm`, String) - expected_cm = joinpath(@__DIR__, "project", "portable", "portable_script_custom.toml") + script_cm = joinpath(@__DIR__, "project", "scripts", "script_custom_manifest.jl") + output_cm = read(`$(Base.julia_cmd()) --startup-file=no $script_cm`, String) + expected_cm = joinpath(@__DIR__, "project", "scripts", "script_custom.toml") - @test occursin("Active project: $portable_script_cm", output_cm) + @test occursin("Active project: $script_cm", output_cm) @test occursin("Active manifest: $expected_cm", output_cm) @test occursin("✓ Custom manifest file is being used: $expected_cm", output_cm) @test occursin("✓ Random.rand()", output_cm) @test occursin("✓ All checks passed!", output_cm) - # Test @script behavior with portable script + # Test @script behavior with script # When using --project=@script, it should use the script file as the project - output_script = read(`$(Base.julia_cmd()) --startup-file=no --project=@script $portable_script`, String) - @test occursin("Active project: $portable_script", output_script) - @test occursin("Active manifest: $portable_script", output_script) + output_script = read(`$(Base.julia_cmd()) --startup-file=no --project=@script $script`, String) + @test occursin("Active project: $script", output_script) + @test occursin("Active manifest: $script", output_script) @test occursin("✓ Random (stdlib) loaded successfully", output_script) - # Test that regular Julia files (without inline sections) work fine as projects - regular_script = joinpath(@__DIR__, "project", "portable", "regular_script.jl") + # Test that regular Julia files (without #!script) can be set as active project + regular_script = joinpath(@__DIR__, "project", "scripts", "regular_script.jl") # Running the script with --project= should set it as active project output = read(`$(Base.julia_cmd()) --startup-file=no --project=$regular_script $regular_script`, String) @@ -1918,37 +1915,40 @@ module M58272_to end @test occursin("Hello from regular script", output) @test occursin("x = 42", output) - portable_script_missing = joinpath(@__DIR__, "project", "portable", "portable_script_missing_dep.jl") + script_missing = joinpath(@__DIR__, "project", "scripts", "script_missing_dep.jl") err_output = IOBuffer() - result = run(pipeline(ignorestatus(`$(Base.julia_cmd()) --startup-file=no $portable_script_missing`), stderr=err_output)) + result = run(pipeline(ignorestatus(`$(Base.julia_cmd()) --startup-file=no $script_missing`), stderr=err_output)) @test !success(result) @test occursin("Package Rot13 not found in current path", String(take!(err_output))) - # Test 1: Project section not first (has code before it) - invalid_project_not_first = joinpath(@__DIR__, "project", "portable", "invalid_project_not_first.jl") - err_output = IOBuffer() - result = run(pipeline(ignorestatus(`$(Base.julia_cmd()) --startup-file=no $invalid_project_not_first`), stderr=err_output)) - @test !success(result) - @test occursin("#!project section must come first", String(take!(err_output))) + # Test 1: #!script marker not at top (has code before it) - runs as regular script + invalid_project_not_first = joinpath(@__DIR__, "project", "scripts", "invalid_project_not_first.jl") + result = run(pipeline(ignorestatus(`$(Base.julia_cmd()) --startup-file=no $invalid_project_not_first`))) + @test success(result) - # Test 2: Manifest section not last (has code after it) - invalid_manifest_not_last = joinpath(@__DIR__, "project", "portable", "invalid_manifest_not_last.jl") + # Test 2: Manifest section before project section - should error when parsed + invalid_manifest_not_last = joinpath(@__DIR__, "project", "scripts", "invalid_manifest_not_last.jl") err_output = IOBuffer() result = run(pipeline(ignorestatus(`$(Base.julia_cmd()) --startup-file=no $invalid_manifest_not_last`), stderr=err_output)) @test !success(result) - @test occursin("#!manifest section must come last", String(take!(err_output))) + @test occursin("#!manifest section must come after #!project section", String(take!(err_output))) - # Test 3: Project not first, but manifest present - invalid_both = joinpath(@__DIR__, "project", "portable", "invalid_both.jl") + # Test 3: Code before #!script marker with --project - should error + invalid_both = joinpath(@__DIR__, "project", "scripts", "invalid_both.jl") err_output = IOBuffer() result = run(pipeline(ignorestatus(`$(Base.julia_cmd()) --startup-file=no --project=$invalid_both -e "using Test"`), stderr=err_output)) @test !success(result) - @test occursin("#!project section must come first", String(take!(err_output))) + @test occursin("is missing #!script marker", String(take!(err_output))) + + # Test 4: Code between sections is now valid + valid_code_between = joinpath(@__DIR__, "project", "scripts", "valid_code_between.jl") + result = run(pipeline(ignorestatus(`$(Base.julia_cmd()) --startup-file=no $valid_code_between`))) + @test success(result) - # Test 4: Manifest with code in between sections - invalid_code_between = joinpath(@__DIR__, "project", "portable", "invalid_code_between.jl") + # Test 5: Using --project on a non-script file errors when loading packages + regular_script = joinpath(@__DIR__, "project", "scripts", "regular_script.jl") err_output = IOBuffer() - result = run(pipeline(ignorestatus(`$(Base.julia_cmd()) --startup-file=no --project=$invalid_code_between -e "using Test"`), stderr=err_output)) + result = run(pipeline(ignorestatus(`$(Base.julia_cmd()) --startup-file=no --project=$regular_script -e "using Test"`), stderr=err_output)) @test !success(result) - @test occursin("#!manifest section must come last", String(take!(err_output))) + @test occursin("is missing #!script marker", String(take!(err_output))) end diff --git a/test/project/portable/invalid_both.jl b/test/project/scripts/invalid_both.jl similarity index 90% rename from test/project/portable/invalid_both.jl rename to test/project/scripts/invalid_both.jl index 8bcd3298f8120..acb69e8f4943e 100644 --- a/test/project/portable/invalid_both.jl +++ b/test/project/scripts/invalid_both.jl @@ -2,6 +2,8 @@ function foo() return 42 end +#!script + #!project begin #!project end diff --git a/test/project/portable/invalid_manifest_not_last.jl b/test/project/scripts/invalid_manifest_not_last.jl similarity index 71% rename from test/project/portable/invalid_manifest_not_last.jl rename to test/project/scripts/invalid_manifest_not_last.jl index 0513adf51cdbe..2e6ff5e6cb103 100644 --- a/test/project/portable/invalid_manifest_not_last.jl +++ b/test/project/scripts/invalid_manifest_not_last.jl @@ -1,10 +1,12 @@ -#!project begin -# [deps] -# Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" -#!project end +#!script #!manifest begin # julia_version = "1.11.0" #!manifest end -println("Code after manifest") +#!project begin +# [deps] +# Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +#!project end + +println("Manifest before project - should error") diff --git a/test/project/portable/invalid_project_not_first.jl b/test/project/scripts/invalid_project_not_first.jl similarity index 61% rename from test/project/portable/invalid_project_not_first.jl rename to test/project/scripts/invalid_project_not_first.jl index 97bd1df41dba4..d8605d7654588 100644 --- a/test/project/portable/invalid_project_not_first.jl +++ b/test/project/scripts/invalid_project_not_first.jl @@ -1,7 +1,9 @@ -# Some code before project section +# Some code before #!script marker x = 1 +#!script + #!project begin # name = "Test" #!project end diff --git a/test/project/portable/regular_script.jl b/test/project/scripts/regular_script.jl similarity index 100% rename from test/project/portable/regular_script.jl rename to test/project/scripts/regular_script.jl diff --git a/test/project/portable/portable_script.jl b/test/project/scripts/script.jl similarity index 72% rename from test/project/portable/portable_script.jl rename to test/project/scripts/script.jl index 0dff1310da954..9aad0fbbabbd9 100644 --- a/test/project/portable/portable_script.jl +++ b/test/project/scripts/script.jl @@ -1,4 +1,5 @@ #!/usr/bin/env julia +#!script #!project begin # name = "PortableScriptTest" @@ -14,29 +15,28 @@ using Random using Test using Rot13 -# Verify the portable script environment is active +# Verify the script environment is active println("Active project: ", Base.active_project()) println("Active manifest: ", Base.active_manifest()) println() -# Test that stdlib packages work -@testset "Portable Script Tests" begin - # Test Random (stdlib) - Random.seed!(42) - r = rand() - @test 0 <= r <= 1 - println("✓ Random (stdlib) loaded successfully") - # Test Rot13 (path-based dependency) - @test Rot13.rot13("Hello") == "Uryyb" - @test Rot13.rot13("World") == "Jbeyq" - println("✓ Rot13 (path dependency) loaded successfully") +# Test Random (stdlib) +Random.seed!(42) +r = rand() +@test 0 <= r <= 1 +println("✓ Random (stdlib) loaded successfully") + +# Test Rot13 (path-based dependency) +@test Rot13.rot13("Hello") == "Uryyb" +@test Rot13.rot13("World") == "Jbeyq" +println("✓ Rot13 (path dependency) loaded successfully") + +# Test that Rot13 module has expected functions +@test hasmethod(Rot13.rot13, (Char,)) +@test hasmethod(Rot13.rot13, (AbstractString,)) +println("✓ Rot13 methods available") - # Test that Rot13 module has expected functions - @test hasmethod(Rot13.rot13, (Char,)) - @test hasmethod(Rot13.rot13, (AbstractString,)) - println("✓ Rot13 methods available") -end #!manifest begin diff --git a/test/project/portable/portable_script_custom.toml b/test/project/scripts/script_custom.toml similarity index 100% rename from test/project/portable/portable_script_custom.toml rename to test/project/scripts/script_custom.toml diff --git a/test/project/portable/portable_script_custom_manifest.jl b/test/project/scripts/script_custom_manifest.jl similarity index 86% rename from test/project/portable/portable_script_custom_manifest.jl rename to test/project/scripts/script_custom_manifest.jl index 17c55b327f7d8..9c664e99a9ed5 100644 --- a/test/project/portable/portable_script_custom_manifest.jl +++ b/test/project/scripts/script_custom_manifest.jl @@ -1,10 +1,11 @@ #!/usr/bin/env julia +#!script #!project begin # name = "PortableScriptCustomManifestTest" # uuid = "1a2b3c4d-5e6f-7890-abcd-ef1234567890" # version = "0.1.0" -# manifest = "portable_script_custom.toml" +# manifest = "script_custom.toml" # [deps] # Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" #!project end @@ -16,7 +17,7 @@ println("Active project: ", Base.active_project()) println("Active manifest: ", Base.active_manifest()) # Verify that the active manifest points to our custom manifest file -expected_manifest = joinpath(dirname(Base.active_project()), "portable_script_custom.toml") +expected_manifest = joinpath(dirname(Base.active_project()), "script_custom.toml") actual_manifest = Base.active_manifest() if actual_manifest == expected_manifest diff --git a/test/project/portable/portable_script_missing_dep.jl b/test/project/scripts/script_missing_dep.jl similarity index 95% rename from test/project/portable/portable_script_missing_dep.jl rename to test/project/scripts/script_missing_dep.jl index 5291711dfb48e..9c61a3c432a15 100644 --- a/test/project/portable/portable_script_missing_dep.jl +++ b/test/project/scripts/script_missing_dep.jl @@ -1,4 +1,5 @@ #!/usr/bin/env julia +#!script #!project begin # name = "PortableScriptMissingDep" diff --git a/test/project/portable/invalid_code_between.jl b/test/project/scripts/valid_code_between.jl similarity index 93% rename from test/project/portable/invalid_code_between.jl rename to test/project/scripts/valid_code_between.jl index 72f04961c4301..69efc515ef1ad 100644 --- a/test/project/portable/invalid_code_between.jl +++ b/test/project/scripts/valid_code_between.jl @@ -1,3 +1,5 @@ +#!script + #!project begin #!project end