diff --git a/NEWS.md b/NEWS.md index de6472b35a047..e2ff10a08a8c8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -97,6 +97,7 @@ Standard library changes #### REPL * The Julia REPL now support bracketed paste on Windows which should significantly speed up pasting large code blocks into the REPL ([#59825]) +* The REPL now provides syntax highlighting for input as you type. See the REPL docs for more info about customization. * The display of `AbstractChar`s in the main REPL mode now includes LaTeX input information like what is shown in help mode ([#58181]). * Display of repeated frames and cycles in stack traces has been improved by bracketing them in the trace and treating them consistently ([#55841]). diff --git a/contrib/generate_precompile.jl b/contrib/generate_precompile.jl index 027f703d1db9a..293d82334719b 100644 --- a/contrib/generate_precompile.jl +++ b/contrib/generate_precompile.jl @@ -41,6 +41,7 @@ precompile(Tuple{typeof(Base.Terminals.enable_bracketed_paste), Base.Terminals.T precompile(Tuple{typeof(Base.Terminals.width), Base.Terminals.TTYTerminal}) precompile(Tuple{typeof(Base.Terminals.height), Base.Terminals.TTYTerminal}) precompile(Tuple{typeof(Base.write), Base.Terminals.TTYTerminal, Array{UInt8, 1}}) +precompile(Tuple{typeof(Base.isempty), Base.AnnotatedString{String}} # loading.jl - without these each precompile worker would precompile these because they're hit before pkgimages are loaded precompile(Base.__require, (Module, Symbol)) diff --git a/stdlib/REPL/docs/src/index.md b/stdlib/REPL/docs/src/index.md index ddd0a0953fcfc..47a386d867f0f 100644 --- a/stdlib/REPL/docs/src/index.md +++ b/stdlib/REPL/docs/src/index.md @@ -533,6 +533,183 @@ mmap(file::AbstractString, ::Type{T}, len::Integer) where T<:BitArray in Mmap at mmap(file::AbstractString, ::Type{T}, len::Integer, offset::Integer; grow, shared) where T<:BitArray in Mmap at Mmap/src/Mmap.jl:322 ``` +## Syntax Highlighting + +The REPL provides syntax highlighting for input as you type. +Syntax highlighting is enabled by default but can be disabled in your `~/.julia/config/startup.jl`: + +```julia +atreplinit() do repl + repl.options.style_input = false +end +``` + +### Customizing Syntax Highlighting Colors + +The default syntax highlighting theme is quite conservative but can be customized using a TOML file `faces.toml` (https://julialang.github.io/StyledStrings.jl/dev/#stdlib-styledstrings-face-toml) in `.julia/config` (or by explicitly loading the faces from a face toml file). + + +
+Example: Monokai color theme (click to expand) + +```toml +# Monokai color theme for Julia syntax highlighting + +[julia_macro] +foreground = "#A6E22E" + +[julia_symbol] +foreground = "#AE81FF" + +[julia_singleton_identifier] +inherit = "julia_symbol" + +[julia_type] +foreground = "#66D9EF" + +[julia_typedec] +foreground = "#66D9EF" +weight = "bold" + +[julia_comment] +foreground = "#75715E" +italic = true + +[julia_string] +foreground = "#E6DB74" + +[julia_regex] +inherit = "julia_string" + +[julia_backslash_literal] +foreground = "#FD971F" +inherit = "julia_string" + +[julia_string_delim] +foreground = "#E6DB74" +weight = "bold" + +[julia_cmdstring] +inherit = "julia_string" + +[julia_char] +inherit = "julia_string" + +[julia_char_delim] +inherit = "julia_string_delim" + +[julia_number] +foreground = "#AE81FF" + +[julia_bool] +foreground = "#AE81FF" +weight = "bold" + +[julia_funcall] +foreground = "#A6E22E" + +[julia_broadcast] +foreground = "#F92672" +weight = "bold" + +[julia_builtin] +foreground = "#66D9EF" +weight = "bold" + +[julia_operator] +foreground = "#F92672" + +[julia_comparator] +inherit = "julia_operator" + +[julia_assignment] +foreground = "#F92672" +weight = "bold" + +[julia_keyword] +foreground = "#F92672" +weight = "bold" + +[julia_parentheses] +foreground = "#F8F8F2" + +[julia_unpaired_parentheses] +background = "#F92672" +foreground = "#F8F8F0" +weight = "bold" + +[julia_error] +background = "#F92672" +foreground = "#F8F8F0" + +[julia_rainbow_paren_1] +foreground = "#A6E22E" +inherit = "julia_parentheses" + +[julia_rainbow_paren_2] +foreground = "#66D9EF" +inherit = "julia_parentheses" + +[julia_rainbow_paren_3] +foreground = "#FD971F" +inherit = "julia_parentheses" + +[julia_rainbow_paren_4] +inherit = "julia_rainbow_paren_1" + +[julia_rainbow_paren_5] +inherit = "julia_rainbow_paren_2" + +[julia_rainbow_paren_6] +inherit = "julia_rainbow_paren_3" + +# Rainbow brackets +[julia_rainbow_bracket_1] +foreground = "#AE81FF" +inherit = "julia_parentheses" + +[julia_rainbow_bracket_2] +foreground = "#E6DB74" +inherit = "julia_parentheses" + +[julia_rainbow_bracket_3] +inherit = "julia_rainbow_bracket_1" + +[julia_rainbow_bracket_4] +inherit = "julia_rainbow_bracket_2" + +[julia_rainbow_bracket_5] +inherit = "julia_rainbow_bracket_1" + +[julia_rainbow_bracket_6] +inherit = "julia_rainbow_bracket_2" + +# Rainbow curlies +[julia_rainbow_curly_1] +foreground = "#F92672" +inherit = "julia_parentheses" + +[julia_rainbow_curly_2] +foreground = "#A6E22E" +inherit = "julia_parentheses" + +[julia_rainbow_curly_3] +inherit = "julia_rainbow_curly_1" + +[julia_rainbow_curly_4] +inherit = "julia_rainbow_curly_2" + +[julia_rainbow_curly_5] +inherit = "julia_rainbow_curly_1" + +[julia_rainbow_curly_6] +inherit = "julia_rainbow_curly_2" +``` + +
+ +For a complete list of customizable faces, see the [JuliaSyntaxHighlighting package documentation](https://julialang.github.io/JuliaSyntaxHighlighting.jl/dev/). + ## Customizing Colors The colors used by Julia and the REPL can be customized, as well. To change the diff --git a/stdlib/REPL/src/LineEdit.jl b/stdlib/REPL/src/LineEdit.jl index ba4a8155b13bf..53cb5efe44d44 100644 --- a/stdlib/REPL/src/LineEdit.jl +++ b/stdlib/REPL/src/LineEdit.jl @@ -4,10 +4,13 @@ module LineEdit import ..REPL using ..REPL: AbstractREPL, Options +using ..REPL.StylingPasses: StylingPass, SyntaxHighlightPass, RegionHighlightPass, EnclosingParenHighlightPass, StylingContext, apply_styling_passes, merge_annotations using ..Terminals import ..Terminals: raw!, width, height, clear_line, beep +using StyledStrings + import Base: ensureroom, show, AnyDict, position using Base: something @@ -58,6 +61,7 @@ mutable struct Prompt <: TextInterface on_done::Function hist::HistoryProvider # TODO?: rename this `hp` (consistency with other TextInterfaces), or is the type-assert useful for mode(s)? sticky::Bool + styling_passes::Vector{StylingPass} # Styling passes to apply to input end show(io::IO, x::Prompt) = show(io, string("Prompt(\"", prompt_string(x.prompt), "\",...)")) @@ -565,6 +569,8 @@ function maybe_show_hint(s::PromptState) return nothing end +max_highlight_size::Int = 10000 # bytes + function refresh_multi_line(s::PromptState; kw...) if s.refresh_wait !== nothing close(s.refresh_wait) @@ -611,6 +617,42 @@ function refresh_multi_line(termbuf::TerminalBuffer, terminal::UnixTerminal, buf reader isa Base.TTY && !Base.ispty(reader)::Bool else false end + # Get the styling passes from the prompt + prompt_obj = nothing + if prompt isa PromptState + prompt_obj = prompt.p + elseif prompt isa PrefixSearchState || prompt isa SearchState + if isdefined(prompt, :parent) && prompt.parent isa Prompt + prompt_obj = prompt.parent + end + end + + styled_buffer = AnnotatedString("") + if buf.size > 0 && buf.size <= max_highlight_size + full_input = String(buf.data[1:buf.size]) + if !isempty(full_input) + passes = StylingPass[] + context = StylingContext(buf_pos, regstart, regstop) + + # Add prompt-specific styling passes if the prompt has them and styling is enabled + enable_style_input = prompt_obj === nothing ? false : + (isdefined(prompt_obj, :repl) && prompt_obj.repl !== nothing ? + prompt_obj.repl.options.style_input : false) + + if enable_style_input && prompt_obj !== nothing + append!(passes, prompt_obj.styling_passes) + end + + if region_active + push!(passes, RegionHighlightPass()) + end + + if !isempty(passes) + styled_buffer = apply_styling_passes(full_input, passes, context) + end + end + end + # Now go through the buffer line by line seek(buf, 0) moreinput = true # add a blank line if there is a trailing newline on the last line @@ -636,12 +678,26 @@ function refresh_multi_line(termbuf::TerminalBuffer, terminal::UnixTerminal, buf llength = textwidth(line) slength = sizeof(line) cur_row += 1 - # lwrite: what will be written to termbuf - lwrite = region_active ? highlight_region(line, regstart, regstop, written, slength) : - line + + # Extract the portion of styled_buffer corresponding to this line. + if !isempty(styled_buffer) + # Calculate byte positions for this line in the buffer + line_start_byte = written + 1 + line_end_byte = written + slength + + # Convert to valid character indices (handles UTF-8 boundaries) + start_idx = thisind(styled_buffer, line_start_byte) + end_idx = thisind(styled_buffer, line_end_byte) + + lwrite = @view styled_buffer[start_idx:end_idx] + else + lwrite = line + end + written += slength cmove_col(termbuf, lindent + 1) - write(termbuf, lwrite) + + write(IOContext(termbuf, :color => hascolor(terminal)), lwrite) # We expect to be line after the last valid output line (due to # the '\n' at the end of the previous line) if curs_row == -1 @@ -692,18 +748,6 @@ function refresh_multi_line(termbuf::TerminalBuffer, terminal::UnixTerminal, buf return InputAreaState(cur_row, curs_row) end -function highlight_region(lwrite::Union{String,SubString{String}}, regstart::Int, regstop::Int, written::Int, slength::Int) - if written <= regstop <= written+slength - i = thisind(lwrite, regstop-written) - lwrite = lwrite[1:i] * Base.disable_text_style[:reverse] * lwrite[nextind(lwrite, i):end] - end - if written <= regstart <= written+slength - i = thisind(lwrite, regstart-written) - lwrite = lwrite[1:i] * Base.text_colors[:reverse] * lwrite[nextind(lwrite, i):end] - end - return lwrite -end - function refresh_multi_line(terminal::UnixTerminal, args...; kwargs...) outbuf = IOBuffer() termbuf = TerminalBuffer(outbuf) @@ -999,7 +1043,9 @@ function edit_insert(s::PromptState, c::StringLike) offset += position(buf) - beginofline(buf) # size of current line spinner = '\0' delayup = !eof(buf) || old_wait - if offset + textwidth(str) <= w && !(after == 0 && delayup) + # Disable fast path when syntax highlighting is enabled + use_fast_path = offset + textwidth(str) <= w && !(after == 0 && delayup) && !options(s).style_input + if use_fast_path # Avoid full update when appending characters to the end # and an update of curs_row isn't necessary (conservatively estimated) write(termbuf, str) @@ -2226,6 +2272,13 @@ function replace_line(s::PrefixSearchState, l::Union{String,SubString{String}}) nothing end +function write_prompt(terminal, s::SearchState, color::Bool) + failed = s.failed ? "failed " : "" + promptstr = s.backward ? "($(failed)reverse-i-search)`" : "($(failed)forward-i-search)`" + write(terminal, promptstr) + return textwidth(promptstr) +end + function refresh_multi_line(termbuf::TerminalBuffer, s::SearchState) buf = IOBuffer() unsafe_write(buf, pointer(s.query_buffer.data), s.query_buffer.ptr-1) @@ -2236,9 +2289,7 @@ function refresh_multi_line(termbuf::TerminalBuffer, s::SearchState) write(buf, read(s.response_buffer, String)) buf.ptr = offset + ptr - 1 s.response_buffer.ptr = ptr - failed = s.failed ? "failed " : "" - ias = refresh_multi_line(termbuf, s.terminal, buf, s.ias, - s.backward ? "($(failed)reverse-i-search)`" : "($(failed)forward-i-search)`") + ias = refresh_multi_line(termbuf, s.terminal, buf, s.ias, s) s.ias = ias return ias end @@ -2823,10 +2874,11 @@ function Prompt(prompt on_enter = default_enter_cb, on_done = ()->nothing, hist = EmptyHistoryProvider(), - sticky = false) + sticky = false, + styling_passes = StylingPass[]) return Prompt(prompt, prompt_prefix, prompt_suffix, output_prefix, output_prefix_prefix, output_prefix_suffix, - keymap_dict, repl, complete, on_enter, on_done, hist, sticky) + keymap_dict, repl, complete, on_enter, on_done, hist, sticky, styling_passes) end run_interface(::Prompt) = nothing diff --git a/stdlib/REPL/src/REPL.jl b/stdlib/REPL/src/REPL.jl index 4fd4989b9ed24..f1f23ab1c382d 100644 --- a/stdlib/REPL/src/REPL.jl +++ b/stdlib/REPL/src/REPL.jl @@ -66,6 +66,8 @@ using Base.Terminals abstract type AbstractREPL end include("options.jl") +include("StylingPasses.jl") +using .StylingPasses include("LineEdit.jl") using .LineEdit @@ -1329,7 +1331,11 @@ function setup_interface( (repl.envcolors ? Base.input_color : repl.input_color) : "", repl = repl, complete = replc, - on_enter = return_callback) + on_enter = return_callback, + styling_passes = StylingPasses.StylingPass[ + StylingPasses.SyntaxHighlightPass(), + StylingPasses.EnclosingParenHighlightPass() + ]) # Setup help mode help_mode = Prompt(contextual_prompt(repl, HELP_PROMPT), diff --git a/stdlib/REPL/src/StylingPasses.jl b/stdlib/REPL/src/StylingPasses.jl new file mode 100644 index 0000000000000..d3558dd925b29 --- /dev/null +++ b/stdlib/REPL/src/StylingPasses.jl @@ -0,0 +1,165 @@ +# This file is a part of Julia. License is MIT: https://julialang.org/license + +# Each pass takes the input string and returns an AnnotatedString with styling annotations + +module StylingPasses + +using StyledStrings +using StyledStrings: Face +using JuliaSyntaxHighlighting +import Base: AnnotatedString, annotate!, annotations, JuliaSyntax + +export StylingPass, StylingContext, SyntaxHighlightPass, RegionHighlightPass, + EnclosingParenHighlightPass, apply_styling_passes, merge_annotations + +# Context information passed to all styling passes +struct StylingContext + cursor_pos::Int + region_start::Int + region_stop::Int +end + +StylingContext(cursor_pos::Int) = StylingContext(cursor_pos, 0, 0) + +abstract type StylingPass end + +function merge_annotations(annotated_strings::Vector{<:AnnotatedString}) + isempty(annotated_strings) && return AnnotatedString("") + + result = AnnotatedString(annotated_strings[1]) + + for source in annotated_strings + for ann in annotations(source) + annotate!(result, ann.region, ann.label, ann.value) + end + end + + return result +end + +function apply_style(pass::StylingPass, input::String, context::StylingContext) + return pass(input, context)::AnnotatedString{String} +end + +function apply_styling_passes(input::String, passes::Vector{StylingPass}, context::StylingContext) + if isempty(passes) + return AnnotatedString(input) + end + + results = [apply_style(pass, input, context) for pass in passes] + return merge_annotations(results) +end + +# Applies Julia syntax highlighting +struct SyntaxHighlightPass <: StylingPass end + +function (::SyntaxHighlightPass)(input::String, ::StylingContext) + try + return JuliaSyntaxHighlighting.highlight(input) + catch e + e isa InterruptException && rethrow() + @error "Error in SyntaxHighlightPass" exception=(e, catch_backtrace()) maxlog=1 + return AnnotatedString(input) + end +end + +# Applies inverse video styling to the selected region +struct RegionHighlightPass <: StylingPass end + +function (::RegionHighlightPass)(input::String, context::StylingContext) + result = AnnotatedString(input) + + if context.region_start > 0 && context.region_stop >= context.region_start + # Add inverse face to the region + # Region positions are 1-based byte positions + region_range = context.region_start:context.region_stop + annotate!(result, region_range, :face, Face(inverse=true)) + end + + return result +end + +# Applies bold styling to parentheses that enclose the cursor position +struct EnclosingParenHighlightPass <: StylingPass + face::Face +end + +EnclosingParenHighlightPass() = EnclosingParenHighlightPass(Face(weight=:bold, underline=true)) + +function (pass::EnclosingParenHighlightPass)(input::String, context::StylingContext) + result = AnnotatedString(input) + + if isempty(input) || context.cursor_pos < 1 + return result + end + + try + ast = JuliaSyntax.parseall(JuliaSyntax.GreenNode, input; ignore_errors=true) + paren_pairs = find_enclosing_parens(input, ast, context.cursor_pos) + + for (open_pos, close_pos) in paren_pairs + annotate!(result, open_pos:open_pos, :face, pass.face) + annotate!(result, close_pos:close_pos, :face, pass.face) + end + catch e + e isa InterruptException && rethrow() + @error "Error in EnclosingParenHighlightPass" exception=(e, catch_backtrace()) maxlog=1 + end + + return result +end + +function paren_type(k) + if k == JuliaSyntax.K"("; 1, :paren + elseif k == JuliaSyntax.K")"; -1, :paren + elseif k == JuliaSyntax.K"["; 1, :bracket + elseif k == JuliaSyntax.K"]"; -1, :bracket + elseif k == JuliaSyntax.K"{"; 1, :curly + elseif k == JuliaSyntax.K"}"; -1, :curly + else 0, :none + end +end + +function find_enclosing_parens(content::String, ast, cursor_pos::Int) + innermost_pairs = Dict{Symbol,Tuple{Int,Int}}() + paren_stack = Tuple{Int,Int,Symbol}[] # (open_pos, depth, type) + + walk_tree(ast, content, 0) do node, offset + nkind = JuliaSyntax.kind(node) + pos = firstindex(content) + offset + + depthchange, ptype = paren_type(nkind) + + if ptype != :none + if depthchange > 0 + # Opening paren - push to stack + push!(paren_stack, (pos, length(paren_stack) + 1, ptype)) + elseif depthchange < 0 && !isempty(paren_stack) + # Closing paren - pop from stack and check if cursor is inside + open_pos, depth, open_ptype = pop!(paren_stack) + if open_ptype == ptype && open_pos <= cursor_pos < pos + # Cursor is inside this paren pair - keep only innermost per type + # Only update if this is the first pair or if it's smaller (more inner) than existing + if !haskey(innermost_pairs, ptype) || (pos - open_pos) < (innermost_pairs[ptype][2] - innermost_pairs[ptype][1]) + innermost_pairs[ptype] = (open_pos, pos) + end + end + end + end + end + + return collect(values(innermost_pairs)) +end + +function walk_tree(f::Function, node, content::String, offset::Int) + f(node, offset) + + if JuliaSyntax.numchildren(node) > 0 + for child in JuliaSyntax.children(node) + walk_tree(f, child, content, offset) + offset += JuliaSyntax.span(child) + end + end +end + +end # module StylingPasses diff --git a/stdlib/REPL/src/options.jl b/stdlib/REPL/src/options.jl index 505e761a04a1e..fa46c49243bfd 100644 --- a/stdlib/REPL/src/options.jl +++ b/stdlib/REPL/src/options.jl @@ -28,6 +28,7 @@ mutable struct Options # refresh after time delay auto_refresh_time_delay::Float64 hint_tab_completes::Bool + style_input::Bool # enable syntax highlighting for input # default IOContext settings at the REPL iocontext::Dict{Symbol,Any} end @@ -49,6 +50,7 @@ Options(; auto_indent_time_threshold = 0.005, auto_refresh_time_delay = 0.0, # this no longer seems beneficial hint_tab_completes = true, + style_input = true, iocontext = Dict{Symbol,Any}()) = Options(hascolor, extra_keymap, tabwidth, kill_ring_max, region_animation_duration, @@ -57,7 +59,7 @@ Options(; backspace_align, backspace_adjust, confirm_exit, auto_indent, auto_indent_tmp_off, auto_indent_bracketed_paste, auto_indent_time_threshold, auto_refresh_time_delay, - hint_tab_completes, + hint_tab_completes, style_input, iocontext) # for use by REPLs not having an options field diff --git a/stdlib/REPL/test/repl.jl b/stdlib/REPL/test/repl.jl index 4a63ea12ac5d0..3d8d75004eaa0 100644 --- a/stdlib/REPL/test/repl.jl +++ b/stdlib/REPL/test/repl.jl @@ -67,7 +67,7 @@ end #end # REPL tests -function fake_repl(@nospecialize(f); options::REPL.Options=REPL.Options(confirm_exit=false)) +function fake_repl(@nospecialize(f); options::REPL.Options=REPL.Options(confirm_exit=false,style_input=false)) # Use pipes so we can easily do blocking reads # In the future if we want we can add a test that the right object # gets displayed by intercepting the display @@ -121,7 +121,7 @@ end # in the mix. If verification needs to be done, keep it to the bare minimum. Basically # this should make sure nothing crashes without depending on how exactly the control # characters are being used. -fake_repl(options = REPL.Options(confirm_exit=false,hascolor=true)) do stdin_write, stdout_read, repl +fake_repl(options = REPL.Options(confirm_exit=false,hascolor=true,style_input=false)) do stdin_write, stdout_read, repl repl.specialdisplay = REPL.REPLDisplay(repl) repl.history_file = false @@ -1893,7 +1893,7 @@ fake_repl() do stdin_write, stdout_read, repl Base.wait(repltask) end ## hints disabled -fake_repl(options=REPL.Options(confirm_exit=false,hascolor=true,hint_tab_completes=false)) do stdin_write, stdout_read, repl +fake_repl(options=REPL.Options(confirm_exit=false,hascolor=true,hint_tab_completes=false,style_input=false)) do stdin_write, stdout_read, repl repltask = @async begin REPL.run_repl(repl) end @@ -2029,3 +2029,123 @@ end @test occursin("'+': ASCII/Unicode U+002B (category Sm: Symbol, math)", output) @test !occursin(", input as ", output) end + +# Test syntax highlighting in REPL input +@testset "Syntax highlighting" begin + using StyledStrings + using REPL.StylingPasses + + # Use withfaces to ensure consistent face definitions regardless of user config + StyledStrings.withfaces(:julia_keyword => StyledStrings.Face(foreground=:red), + :julia_number => StyledStrings.Face(foreground=:blue)) do + + # Test that julia_prompt has syntax highlighting passes + fake_repl(options = REPL.Options(confirm_exit=false, style_input=true)) do stdin_write, stdout_read, repl + repl.interface = REPL.setup_interface(repl) + julia_prompt = repl.interface.modes[1] + shell_mode = repl.interface.modes[3] + + # Julia prompt should have syntax highlighting passes + @test length(julia_prompt.styling_passes) == 2 + @test any(p -> p isa StylingPasses.SyntaxHighlightPass, julia_prompt.styling_passes) + @test any(p -> p isa StylingPasses.EnclosingParenHighlightPass, julia_prompt.styling_passes) + + # Shell mode should not have syntax highlighting passes + @test length(shell_mode.styling_passes) == 0 + + # Test that syntax highlighting is actually applied + repltask = @async begin + REPL.run_repl(repl) + end + + # Test 1: Simple keyword highlighting + write(stdin_write, "function") + s = readuntil(stdout_read, "function", keep=true) + # The keyword "function" should be styled (have escape code before it) + # Look for "function" that appears after the prompt, not just anywhere + # Extract just the input portion after "julia> " + input_part = split(s, "julia> ", keepempty=false) + if !isempty(input_part) + input_text = input_part[end] + # If syntax highlighting is working, "function" will have an escape code before it + # like \e[31mfunction or similar + @test occursin(r"\e\[[0-9;]*m.*function", input_text) + end + write(stdin_write, "\x03") # Ctrl-C to cancel + + # Test 2: Unicode identifiers with syntax highlighting + readuntil(stdout_read, "julia> ") + write(stdin_write, "function αβ(a, β)") + s = readuntil(stdout_read, "β)", keep=true) + # Should highlight "function" keyword even with unicode following + input_part = split(s, "julia> ", keepempty=false) + if !isempty(input_part) + input_text = input_part[end] + # Keyword should be styled + @test occursin(r"\e\[[0-9;]*m.*function", input_text) + end + # Unicode should be preserved (may have ANSI codes interleaved, so check separately) + @test occursin("α", s) + @test occursin("β", s) + @test occursin("(", s) + @test occursin(")", s) + write(stdin_write, "\x03") # Ctrl-C to cancel + + # Test 3: Multi-line input with syntax highlighting + readuntil(stdout_read, "julia> ") + write(stdin_write, "begin\n") + readuntil(stdout_read, "begin") + write(stdin_write, " local test_var_for_highlighting = 42\n") + s = readuntil(stdout_read, "42", keep=true) + # Should contain highlighting - the "local" keyword should be styled + @test occursin(r"\e\[[0-9;]*m.*local", s) + write(stdin_write, "\x03") # Ctrl-C to cancel before executing + # Don't execute to avoid polluting Main module + + # Test 4: Bracket highlighting (paren matching) + readuntil(stdout_read, "julia> ") + write(stdin_write, "(1 + (2 * 3))") + # Move cursor to be inside the inner parens: between 2 and * + # Current position is at end: (1 + (2 * 3))| + # Move left 5 times to get to: (1 + (2| * 3)) + for _ in 1:5 + write(stdin_write, "\e[D") # Left arrow + end + # Give it a moment to process and re-render + sleep(0.1) + # Now write a character to trigger re-render and capture output + write(stdin_write, " ") + s = readuntil(stdout_read, " ", keep=true) + # The enclosing parens around "2 * 3" should be highlighted with bold/underline + # We can't easily test the exact positioning, but we can verify that + # there are ANSI codes for bold (\e[1m) or underline (\e[4m) present + @test occursin(r"\e\[[0-9;]*[14]m", s) # Contains bold or underline codes + write(stdin_write, "\x03") # Ctrl-C to cancel + + write(stdin_write, '\x04') # Exit + Base.wait(repltask) + end + + # Test that syntax highlighting can be disabled + fake_repl(options = REPL.Options(confirm_exit=false, style_input=false)) do stdin_write, stdout_read, repl + repl.interface = REPL.setup_interface(repl) + + repltask = @async begin + REPL.run_repl(repl) + end + + # Even though the prompt has styling passes, they shouldn't be applied + write(stdin_write, "function") + s = readuntil(stdout_read, "function", keep=true) + # With style_input=false, there should be no color codes from syntax highlighting + # (there may still be prompt color codes, but not within the input text) + lines = split(s, '\n') + # The last line should contain just "function" without color codes around it + @test occursin("function", s) + + write(stdin_write, "\x03") # Ctrl-C to cancel + write(stdin_write, '\x04') # Exit + Base.wait(repltask) + end + end +end