Only in /var/lib/copr-rpmbuild/results/newbie/upstream-unpacked/Source0/Newbie-1.0.8: .github Only in /var/lib/copr-rpmbuild/results/newbie/upstream-unpacked/Source0/Newbie-1.0.8: .gitignore diff -U2 -r /var/lib/copr-rpmbuild/results/newbie/upstream-unpacked/Source0/Newbie-1.0.8/Cargo.lock /var/lib/copr-rpmbuild/results/newbie/srpm-unpacked/Newbie-1.0.8.tar.gz-extract/Newbie-1.0.8/Cargo.lock --- /var/lib/copr-rpmbuild/results/newbie/upstream-unpacked/Source0/Newbie-1.0.8/Cargo.lock 2025-12-14 18:14:52.000000000 +0000 +++ /var/lib/copr-rpmbuild/results/newbie/srpm-unpacked/Newbie-1.0.8.tar.gz-extract/Newbie-1.0.8/Cargo.lock 2025-12-17 15:13:52.000000000 +0000 @@ -235,7 +235,7 @@ [[package]] name = "dirs" -version = "5.0.1" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ "dirs-sys", @@ -244,12 +244,12 @@ [[package]] name = "dirs-sys" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.48.0", + "windows-sys 0.61.0", ] @@ -493,5 +493,5 @@ [[package]] name = "newbie" -version = "1.0.1" +version = "1.0.8" dependencies = [ "aho-corasick", @@ -641,7 +641,7 @@ [[package]] name = "redox_users" -version = "0.4.6" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", @@ -797,7 +797,7 @@ [[package]] name = "thiserror" -version = "1.0.69" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ "thiserror-impl", @@ -806,7 +806,7 @@ [[package]] name = "thiserror-impl" -version = "1.0.69" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", @@ -1012,18 +1012,9 @@ [[package]] name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] @@ -1034,5 +1025,5 @@ checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] @@ -1048,41 +1039,20 @@ [[package]] name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1091,10 +1061,4 @@ [[package]] name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1103,10 +1067,4 @@ [[package]] name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1121,10 +1079,4 @@ [[package]] name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1133,10 +1085,4 @@ [[package]] name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1145,10 +1091,4 @@ [[package]] name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1156,10 +1096,4 @@ [[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff -U2 -r /var/lib/copr-rpmbuild/results/newbie/upstream-unpacked/Source0/Newbie-1.0.8/Cargo.toml /var/lib/copr-rpmbuild/results/newbie/srpm-unpacked/Newbie-1.0.8.tar.gz-extract/Newbie-1.0.8/Cargo.toml --- /var/lib/copr-rpmbuild/results/newbie/upstream-unpacked/Source0/Newbie-1.0.8/Cargo.toml 2025-12-14 18:14:52.000000000 +0000 +++ /var/lib/copr-rpmbuild/results/newbie/srpm-unpacked/Newbie-1.0.8.tar.gz-extract/Newbie-1.0.8/Cargo.toml 2025-12-27 18:01:57.000000000 +0000 @@ -1,6 +1,6 @@ [package] name = "newbie" -version = "1.0.6" -edition = "2024" +version = "1.0.8" +edition = "2021" authors = ["Mark Allen Battey"] description = "Natural language shell interpreter and text processor" @@ -13,5 +13,5 @@ dirs = "6.0" libc = "0.2" -zbus = "5.12" +zbus = { version = "5.12", features = ["blocking"] } [profile.release] diff -U2 -r /var/lib/copr-rpmbuild/results/newbie/upstream-unpacked/Source0/Newbie-1.0.8/README.txt /var/lib/copr-rpmbuild/results/newbie/srpm-unpacked/Newbie-1.0.8.tar.gz-extract/Newbie-1.0.8/README.txt --- /var/lib/copr-rpmbuild/results/newbie/upstream-unpacked/Source0/Newbie-1.0.8/README.txt 2025-12-14 18:14:52.000000000 +0000 +++ /var/lib/copr-rpmbuild/results/newbie/srpm-unpacked/Newbie-1.0.8.tar.gz-extract/Newbie-1.0.8/README.txt 2025-12-16 15:02:16.000000000 +0000 @@ -1,3 +1,3 @@ -Newbie 1.0 User Guide +Newbie 1.0.7 User Guide Welcome to Newbie! A simple command-line tool for file processing, text manipulation, and automation. @@ -37,5 +37,5 @@ &show myfile.txt &first 10 -Show the last 5 lines: +Show the last 5 lines, this can take a little while on enormous files, but Newbie can handle it: &show myfile.txt &last 5 @@ -49,4 +49,8 @@ &show &files *.txt +Get a file's size in bytes (for use with &math): + &v.size &= &files &size myfile.txt + &show &v.size + Copy a file (© is really just a passthrough to the rsync -a command): © source.txt &to destination.txt @@ -59,4 +63,8 @@ &delete old_directory/ +Create a file or directory. A trailing slash indicates directory: + &create newfile.txt + &create ~/path/to/new/directory/ + Convert file formats. After testing I found the standalone Linux components much faster than the rust crates, and so Newbie uses these, as opposed to native rust. Note that the following file compression formats are transparent in Newbie: &convert myfile.txt &into myfile.bz2 @@ -72,4 +80,20 @@ &write Another line &to greeting.txt +Write to the console (stdout) from a script: + &write Status message &to &display + +UNIX PIPELINE INTEGRATION + +Newbie can read from stdin and write to stdout using &pipeline, enabling integration with Unix pipes. Scripts must be used (bash interprets & specially on the command line). + +Read from stdin: + &find pattern &in &pipeline &into results.txt + +Write to stdout: + &find pattern &in input.txt &into &pipeline + +Filter mode (stdin to stdout): + &find pattern &in &pipeline &into &pipeline + RUNNING EXTERNAL COMMANDS @@ -80,6 +104,4 @@ &show &run &bash date -Do not attempt to run full screen cli apps within Newbie, such as nano or vim. Use another instance of bash for these. Personally, I use a GUI editor. - Run a Newbie script from within a script or interactive session: &run myscript.ns @@ -108,5 +130,40 @@ Clear multiple variables at once: - &empty &v.name &v.age + &empty &v.name &v.age + +ARITHMETIC AND MATH + +Newbie is a text processor, and so treats all characters except EOL and EOF the same. In order for it to use numerical charaters in arithmetic operations the &math keyword must be used: + +Variable assignment: + &v.x &= &math 2 + 2 + &v.area &= &math &v.width * &v.height + &v.result &= &math (10 + 5) * 2 + +Supported operators: + + Addition + - Subtraction + * Multiplication + / Division + < Less than + > Greater than + = Equals + () Parentheses for grouping + +Increment and decrement: + &v.count &= 0 + &v.count++ + &v.count++ + &show &v.count + &v.count-- + &show &v.count + +Using math with conditionals: + &v.score &= 85 + &if &math &v.score > 60 &write Passed! &to results.txt + +Note: &math cannot start a line. Valid forms are: + &v.x &= &math 2+2 + &if &math &v.x > 3 SYSTEM VARIABLES @@ -144,28 +201,42 @@ CONDITIONAL EXECUTION -In Newbie a user variable can have two states: in use, or not. - &v.name &= Tux Penguin - &if &v.name &write Name is set: &v.name &to log.txt +Newbie uses &if as the sole conditional operator, with test types for different conditions. Check if a file exists: - &if myfile.txt &write File exists! &to status.txt - -Empty variables don't execute in this case: - &empty &v.name - &if &v.name &write This won't execute &to log.txt + &if &exists myfile.txt &write File exists! &to status.txt -To execute when variable is empty: - ¬ &if &v.name &write Variable is empty &to log.txt +Check if a file does NOT exist: + &if ¬ &exists missing.txt &write File not found &to errors.txt -To execute when file doesn't exist: - ¬ &if missing.txt &write File not found &to errors.txt - -Check two conditions: - &v.id &= 7 - &v.name &= Tux Penguin - &if &v.id &if &v.name &write &v.id: &v.name &to valid.txt +Check if a variable has a value (variable-first syntax): + &if &v.name &filled &write Name is: &v.name &to log.txt -Check one, negate another: - &if &v.id ¬ &if &v.label &write ID &v.id has no label &to errors.txt +Check if a variable is empty or undefined (variable-first syntax): + &if &v.name &empty &write Name not set &to log.txt + +Alternate syntax (test-first, also valid): + &if &filled &v.name &write Name is set &to log.txt + &if &empty &v.name &write Name not set &to log.txt + +Numeric comparisons with &math: + &v.score &= 75 + &if &math &v.score > 60 &write Passed &to results.txt + &if &math &v.score < 90 &write Room for improvement &to results.txt + &if ¬ &math &v.score > 100 &write Valid score &to results.txt + +The test types: +Testing for a file: + &if &exists Cargo.toml &write Toml exists &to test.txt +Testing if a variable has a value: + &if &v.name &filled &write Variable has value &to &display +Testing if a variable is empty: + &if &v.blank &empty &write Variable is empty &to &display +Testing with &math: + &if &math &v.score < 80 &write Below 80 &to &display + +All can be negated with ¬: + &if ¬ &exists filename + &if ¬ &v.varname &filled + &if ¬ &math expression THE &find COMMAND - PATTERN MATCHING @@ -193,10 +264,9 @@ &last N - Return only the last N matches (buffered) &numbered - Show line numbers (1, 2, 3...) - &lines - Show original line numbers from source file PATTERN SYNTAX Literal Text - Match exact text by typing it directly: - &find error &in logfile.txt + &find error &in logfile.txt &into &display &find Connection refused &in server.log @@ -204,6 +274,7 @@ Variable-length (one or more): - &numbers Matches one or more digits - &letters Matches one or more letters (a-z, A-Z) + &numbers Matches one or more digits (0-9) + &letters Matches one or more ASCII letters (a-z, A-Z) + &text Matches one or more characters (any UTF-8, non-whitespace) &spaces Matches one or more spaces &tabs Matches one or more tabs @@ -211,221 +282,117 @@ Fixed-length: &numbers 4 Matches exactly 4 digits - &letters 3 Matches exactly 3 letters + &letters 3 Matches exactly 3 ASCII letters + &text 5 Matches exactly 5 characters (any UTF-8) &spaces 2 Matches exactly 2 spaces &tabs 1 Matches exactly 1 tab -Examples: - -Find lines with IP addresses (digits separated by dots): - &find &numbers . &numbers . &numbers . &numbers &in access.log - -Find 5-digit ZIP codes: - &find &numbers 5 &in addresses.txt - -Find lines with 3-letter abbreviations: - &find &letters 3 &in data.txt +Note on &letters vs &text: + &letters matches ASCII letters only (a-z, A-Z) + &text matches any Unicode characters including international text + Use &text for multinational data (Chinese, Japanese, Arabic, etc.) -THE &+ (ADJACENCY) OPERATOR - -By default, pattern components can have any amount of text between them. The `&+` operator requires components to be immediately adjacent with no space between. +Examples: + &find Error &numbers &in log.txt + Match lines containing "Error" followed by digits -Without &+: matches "error 123" or "error123" or "error abc 123": - &find error &numbers &in file.txt + &find ID- &letters 3 &numbers 4 &in data.txt + Match patterns like "ID-ABC1234" -With &+: matches only "error123" (no space allowed): - &find error &+ &numbers &in file.txt + &find 名前: &+ &text &in japanese_data.txt + Match Japanese text after "名前:" -Multiple adjacency: match "ID-12345": - &find ID &+ - &+ &numbers 5 &in records.txt + &find Code: &+ &text 8 &in data.txt + Match exactly 8 characters (any language) after "Code:" -START AND END ANCHORS +Positional Operators - Control where patterns match: + &start &= Match at beginning of line + &end &= Match at end of line -&start and &end control whether patterns must match at the beginning or end of lines. +Examples: + &find &start &= Error &in log.txt + Match lines beginning with "Error" -Matches only lines that begin with ERROR: - &find &start &= ERROR &in logfile.txt + &find &end &= .txt &in listing.txt + Match lines ending with ".txt" -Matches only lines that end with .txt: - &find &end &= .txt &in filelist.txt +Anchored Patterns - Use &= to anchor patterns at boundaries: + &find &start &= Error: &in log.txt + Match lines starting exactly with "Error:" -Matches lines that both start with ERROR and end with timeout: - &find &start &= ERROR &end &= timeout &in logfile.txt + &find &end &= @en . &in wikidata.nt + Match lines ending exactly with "@en ." -USING VARIABLES IN PATTERNS +Adjacency Operator - Use &+ to require patterns be immediately adjacent: + &find &start &= http:// &+ www. &in urls.txt + Match URLs that start with "http://www." with no space between -You can use variables in patterns to make them dynamic: - &v.status &= ERROR - &find &v.status &in logfile.txt + &find < &+ &v.id &+ > &in data.xml + Match XML tags with captured ID, no spaces inside -THE &capture COMMAND - EXTRACTING DATA +CAPTURING DATA WITH &capture -While &find just searches for patterns, &capture extracts specific parts of matching lines into variables. The syntax is identical to &find but with variable names in the pattern. &capture is almost always used in a block, though it does support use at the interface. +The &capture command extracts data from text into variables using patterns. It is typically used inside a &block for line-by-line processing. Syntax: - [&show] &capture &in [&into ] [options] - -Extract simple values with literals as fences: - &capture ID: &v.id Name: &v.name &in records.txt + &capture &in -After this command, &v.id and &v.name contain the extracted values. - -Write captured data to a file: - &capture Error &v.code at &v.time &in log.txt &write &v.code,&v.time &to errors.csv - -Use start anchor to match beginning of line: - &capture &start &= [&v.date] &v.level : &v.msg &in system.log &write &v.date|&v.level|&v.msg &to structured.txt - -Capture with character classes: - &capture User &v.name logged in at &v.time &in auth.log - -WORKING WITH BLOCKS +In a block context: + &block input.txt + &capture Name: &v.name &in &newbie.line + &if &v.name &filled &write Found: &v.name &to output.txt + &endblock -The &block command processes a file line-by-line, executing commands for each line. Each line is available in &newbie.line. +Pattern Fences - Use &+ to create boundaries for variable capture: + &capture ID: &+ &v.id &+ , Name: &+ &v.name &in &newbie.line -Basic syntax: - &block datafile.txt - (commands to execute for each line) +This captures: + - Everything between "ID: " and "," into &v.id + - Everything after "Name: " to end of match into &v.name + +BLOCK PROCESSING + +Process files line-by-line with &block: + &block input.txt + &capture pattern &+ &v.data + &if &v.data &filled &write &v.data &to output.txt + &empty &v.data &endblock -Example: Extract status for each line: - &block data.txt - &v.line &= &newbie.line - &capture STATUS: &v.status ID: &v.id &in &v.line - &if &v.status &write &v.id : &v.status &to status_report.txt +Process all files in a directory with &block &files: + &block &files ~/incoming/ + &find ERROR + &show &newbie.line &endblock -SORTING AND LOOKUP - -Sort a file (preserves compression format): - &sort input.txt &into sorted.txt - &sort data.bz2 &into sorted_data.bz2 - -Sort with custom temporary directory: - &sort huge_file.txt &temp /mnt/large_drive &into sorted.txt - -Sort a huge file, using large external drive: - &sort /run/media/mark/bigdrive/Archive/latest-truthy.nt.bz2 &temp /run/media/mark/bigdrive &into /run/media/mark/bigdrive/Archive/sorted.gz - -Newbie normally runs in very little memory, the exception is &lookup, which loads a file into RAM and basically does a bulk find and replace on all the matching items. The lookup file must be no larger than about half your available memory. - -Dictionary-based lookup and replacement. Create a dictionary file with alternating pattern and replacement lines: - pattern1 - replacement1 - pattern2 - replacement2 - -Apply lookups to a file: - &lookup dict.txt &in data.txt &into replaced.txt - -This replaces all occurrences of pattern1 with replacement1, pattern2 with replacement2, etc. The lookup uses the Aho-Corasick algorithm for efficient multi-pattern matching. - -Use cases: -- Entity ID to label mapping -- Code to description translation -- Abbreviation expansion -- Any many-to-one string replacement task - -FILES AND WILDCARDS +This processes every non-hidden file in the directory, iterating through each file's lines sequentially. Subdirectories are skipped. Files are processed in alphabetical order. -List files in current directory: - &files - -List files in a specific directory: - &files ~/Documents/ - -Match files with wildcards: - &files *.txt - &files data*.csv &in /data/ - -COMMON WORKFLOW EXAMPLES +Inside a block: + - &newbie.line contains the current line being processed + - &capture works on &newbie.line by default + - &empty should be used to clear variables between iterations -Example 1: Extract and summarize error logs - &find ERROR &in app.log &into errors.txt - &show errors.txt &first 20 +LOOKUP TABLES -Example 2: Data extraction pipeline - &capture ID: &v.id Name: &v.name &in records.txt &write &v.id,&v.name &to output.csv +Create dictionaries and replace values: + &lookup dictionary.txt &in input.txt &into output.txt -Example 3: Process multiple files - &files *.txt &in ~/data/ &run process_script.ns +Dictionary format (tab-separated): + key1 value1 + key2 value2 -Example 4: Complex pattern with multiple variables - &capture &start &= [&v.date] &v.level : &v.message &in system.log &write &v.date | &v.level | &v.message &to structured.txt +All occurrences of keys in the input are replaced with their values. -Example 5: Conditional data extraction - &block data.txt - &v.line &= &newbie.line - &capture STATUS: &v.status ID: &v.id &in &v.line - &if &v.status &write &v.id : &v.status &to status_report.txt - &endblock - -ADMINISTRATIVE COMMANDS - -Show license: - &license +SORTING -Show all variables: - &vars +Sort lines in a file: + &sort input.txt &into sorted.txt -Exit Newbie: - &exit +REAL-WORLD EXAMPLES -QUICK REFERENCE TABLE +Example 1: Processing Wikidata RDF (wikidata.ns) -Command Purpose Example -&show Display file &show file.txt -&write Append to file &write text &to out.txt -© Copy file © a.txt &to b.txt -&move Move file &move old.txt &to new.txt -&delete Delete file/dir &delete temp.txt -&run Execute command &run BASH ls -&find Pattern search &find error &in log.txt -&capture Extract data &capture &v.id &in data.txt -&block Process file &block data.txt ... &endblock -&if Conditional &if &v.name &write ... &to file -¬ Negate condition ¬ &if &v.x &write ... -&empty Clear variables &empty &v.a &v.b -&first First N lines &show file.txt &first 10 -&last Last N lines &show file.txt &last 5 -&vars List variables &vars -&sort Sort file &sort in.txt &into out.txt -&lookup Dictionary replace &lookup dict.txt &in data.txt -&convert Convert format &convert in.txt &into out.gz -&files List/match files &files *.txt -&wrap Toggle line wrapping &wrap -&admin Run as sudo &admin © protected &to /etc/ -&exit Quit &exit - -TROUBLESHOOTING - -"No command keywords found" -- Make sure commands start with `&` -- Check for typos in keyword names - -Variable not working -- Variables need `&v.` prefix: `&v.name` not `&name` -- Make sure to set before using: `&v.x &= value` - -Condition not working -- Empty variables evaluate to false -- Check if variable was cleared: `&empty &v.name` - -Block not processing -- Verify file exists: `&if input.txt &write exists &to check.txt` -- Check for `&endblock` closing tag - -ADVANCED SCRIPT EXAMPLES - -The following examples demonstrate real-world usage of Newbie for complex data processing tasks. I'd like to thank the people at Wikidata, for maintaining latest-truthy.nt.bz2, which gave me an example of a large compressed file to process that I felt legally safe using as an example. - -Example 1: Wikidata Processing Pipeline (wdtest.ns) - -I've mostly used this example because it is publically available, and a very large file. -This script extracts English-language labels and properties from a compressed Wikidata dump, then performs entity resolution using lookup tables. -The &find command builds a text file of English language records. I could have parsed the fields directly from the bz2 source, but the &find command is much faster, but less detailed than a &block...&capture...&write...&endblock pattern. Also, bz2 is the slowest of the supported compression algorithms, but it is extremely effective on data like the wikidata n-triples, because it's got a lot of duplication. -The &block then uses &empty and &capture to repopulate the variables, which it then writes to the correct text file. -The &lookup command then loads the lookup.txt file into memory and performs a bulk find and replace using it, on the content of direct-properties.txt, with the output going to WDInEnglish.txt +This script extracts and translates data from compressed Wikidata dumps, creating a lookup dictionary and applying it to transform entity IDs into human-readable labels. -&show Started: &+ &+ &system.date &+ &+ &system.time +&write Started: &+ &+ &system.date &+ &+ &system.time &to &display &directory ~/testfolder/ &find &end &= @en . &in /mnt/bigdrive/Archive/latest-truthy.nt.bz2 &into enonly.txt @@ -434,10 +401,10 @@ &capture " &+ &v.label &+ "@en . &capture " &+ &v.label &+ "@en . - &if &v.islabel &write &v.entity &to lookup.txt - &if &v.islabel &write &v.label &to lookup.txt - &if &v.direct &write &v.entity &+ &+ &v.direct &+ &+ &v.label &to direct-properties.txt + &if &v.islabel &filled &write &v.entity &to lookup.txt + &if &v.islabel &filled &write &v.label &to lookup.txt + &if &v.direct &filled &write &v.entity &+ &+ &v.direct &+ &+ &v.label &to direct-properties.txt &endblock &lookup lookup.txt &in direct-properties.txt &into WDInEnglish.txt -&show Finished: &+ &+ &system.date &+ &+ &system.time +&write Finished: &+ &+ &system.date &+ &+ &system.time &to &display What this does: @@ -458,9 +425,9 @@ &directory ~/testfolder/ &block enonly.txt - &empty &v.label &v.entity &v.direct &v.islabel + &empty &v.label &v.entity &v.direct &v.islabel &capture " &+ &v.label &+ "@en . &capture " &+ &v.label &+ "@en . - &if &v.islabel &write INSERT INTO entities (entity,label) VALUES (' &+ &v.entity &+ ', ' &+ &v.label &+ '); &to entities.sql - &if &v.direct &write INSERT INTO properties (entity,direct,label) VALUES (' &+ &v.entity &+ ', ' &+ &v.direct &+ ', ' &+ &v.label &+ '); &to properties.sql + &if &v.islabel &filled &write INSERT INTO entities (entity,label) VALUES (' &+ &v.entity &+ ', ' &+ &v.label &+ '); &to entities.sql + &if &v.direct &filled &write INSERT INTO properties (entity,direct,label) VALUES (' &+ &v.entity &+ ', ' &+ &v.direct &+ ', ' &+ &v.label &+ '); &to properties.sql &endblock @@ -477,166 +444,416 @@ - Clearing variables between iterations for clean state -Example 3: Comprehensive Test Suite (test.ns) +Example 3: Unix Pipeline Filter (filter.ns) + +A simple filter script demonstrating pipeline integration: + +&find pattern &in &pipeline &into &pipeline + +Usage from bash: + cat data.txt | newbie filter.ns | sort > results.txt + +Example 4: File Size Guard (process.ns) + +Check file size before processing: + +&if ¬ &exists input.txt &write Error: input.txt not found &to &display +&if &exists input.txt &block input.txt + &v.size &= &files &size input.txt + &if &math &v.size > 0 &write Processing file of &v.size bytes &to &display +&endblock + +Example 5: Comprehensive Test Suite (test.ns) A complete test suite exercising all Newbie features. Run with: newbie test.ns -The test suite includes 40 tests covering: -- File operations (write, copy, move, delete, show) +The test suite includes tests covering: +- File operations (write, copy, move, delete) - All variable types (user, system, process, network, global, config) -- Conditionals (if, not if, multiple conditions) -- Pattern matching (find with literals, character classes, anchors) +- Conditionals (&if &exists, &if &filled, &if &empty, &if &math, with ¬) +- Arithmetic (&math, increment/decrement) +- Pattern matching (find with literals, character classes, anchors, &text) - Data extraction (capture with variables and fences) - Text processing (first, last, numbered output) - Compression (convert between formats) - Advanced features (lookup, sort, block processing) -- Complex workflows (nested blocks with conditionals) +- UTF-8/International text support (&text pattern) +- Pipeline integration (&pipeline, &display) This test suite serves as both validation and a learning resource showing correct syntax for all commands. It also provides a good example of Newbie comments. Note that any line that does not begin with &keyword is a comment. Whitespace is allowed before the &keyword. There is no comment character. +When complete, the only file remaining in the test directory will be test_results.txt containing all test output. + +============================================================================ Newbie Test Script - test.ns Tests implemented features and provides framework for new features +All output goes to test_results.txt, all temp files are cleaned up +============================================================================ + +&write ============================================================================ &to test_results.txt +&write Newbie 1.0.7 Test Suite &to test_results.txt +&write Started: &system.date &system.time &to test_results.txt +&write ============================================================================ &to test_results.txt ============================================================================ -PHASE 1: Basic File Operations (Currently Working) +PHASE 1: Basic File Operations ============================================================================ -Test 1: Show first lines of a file -&show Cargo.toml &first 10 -Test 2: Show last lines with line numbers -&show src/main.rs &last 20 &numbered +&write PHASE 1: Basic File Operations &to test_results.txt +&write ---------------------------------------- &to test_results.txt + +Test 1: Create test files using &write +&write Line 1 of test file &to _test_file.txt +&write Line 2 of test file &to _test_file.txt +&write Line 3 of test file &to _test_file.txt +&write Line 4 of test file &to _test_file.txt +&write Line 5 of test file &to _test_file.txt +&if &exists _test_file.txt &write Test 1 PASSED: Created test file with &write &to test_results.txt +&if ¬ &exists _test_file.txt &write Test 1 FAILED: Could not create test file &to test_results.txt -Test 3: List files in current directory -&files +Test 2: Copy a file +© _test_file.txt &to _test_copy.txt +&if &exists _test_copy.txt &write Test 2 PASSED: File copied successfully &to test_results.txt +&if ¬ &exists _test_copy.txt &write Test 2 FAILED: Copy failed &to test_results.txt -Test 4: List files with sizes -&show &files . +Test 3: Move/rename a file +&move _test_copy.txt &to _test_renamed.txt +&if &exists _test_renamed.txt &write Test 3 PASSED: File moved/renamed successfully &to test_results.txt +&if ¬ &exists _test_renamed.txt &write Test 3 FAILED: Move failed &to test_results.txt -Test 5: List files including hidden -&files &all +Test 4: Create a directory +&create _testdir/ +&if &exists _testdir/ &write Test 4 PASSED: Directory created &to test_results.txt +&if ¬ &exists _testdir/ &write Test 4 FAILED: Directory creation failed &to test_results.txt + +Test 5: Create file in directory +&write Content in subdirectory &to _testdir/subfile.txt +&if &exists _testdir/subfile.txt &write Test 5 PASSED: File created in directory &to test_results.txt +&if ¬ &exists _testdir/subfile.txt &write Test 5 FAILED: File in directory failed &to test_results.txt ============================================================================ -PHASE 2: Pattern Matching (Currently Working) +PHASE 2: Variable System ============================================================================ -Test 6: Find simple literal pattern -&find TODO &in src/main.rs -Test 7: Find pattern with anchor (if you have test data) -Commented out: &find &end &= Ok(()) &in src/main.rs +&write PHASE 2: Variable System &to test_results.txt +&write ---------------------------------------- &to test_results.txt + +Test 6: Set and check variable +&v.testvar &= Hello from Newbie +&if &v.testvar &filled &write Test 6 PASSED: Variable set successfully: &v.testvar &to test_results.txt +&if &v.testvar &empty &write Test 6 FAILED: Variable not set &to test_results.txt + +Test 7: Clear variable +&empty &v.testvar +&if &v.testvar &empty &write Test 7 PASSED: Variable cleared successfully &to test_results.txt +&if &v.testvar &filled &write Test 7 FAILED: Variable not cleared &to test_results.txt -Test 8: Find and save to file -Commented out: &find Error &in src/main.rs &into errors.txt +Test 8: System variables +&if &system.user &filled &write Test 8 PASSED: System user is &system.user &to test_results.txt +&if &system.user &empty &write Test 8 FAILED: System user not available &to test_results.txt + +Test 9: Process variables +&if &process.pid &filled &write Test 9 PASSED: Process PID is &process.pid &to test_results.txt +&if &process.pid &empty &write Test 9 FAILED: Process PID not available &to test_results.txt ============================================================================ -PHASE 3: External Commands (Currently Working) +PHASE 3: Arithmetic ============================================================================ -Test 9: Silent execution -&run &bash echo "This won't be visible" -Test 10: Visible execution -&show &run &bash echo "This will be visible" +&write PHASE 3: Arithmetic &to test_results.txt +&write ---------------------------------------- &to test_results.txt + +Test 10: Basic math +&v.x &= &math 2 + 2 +&if &math &v.x = 4 &write Test 10 PASSED: 2+2 = &v.x &to test_results.txt +&if ¬ &math &v.x = 4 &write Test 10 FAILED: 2+2 should equal 4, got &v.x &to test_results.txt -Test 11: More complex bash command -&show &run &bash ls -lah | head -5 +Test 11: Math with variables +&v.a &= 10 +&v.b &= 5 +&v.c &= &math &v.a * &v.b +&if &math &v.c = 50 &write Test 11 PASSED: 10*5 = &v.c &to test_results.txt +&if ¬ &math &v.c = 50 &write Test 11 FAILED: 10*5 should equal 50, got &v.c &to test_results.txt -Test 12: Create a test file with bash -&run &bash echo "Line 1\nLine 2\nLine 3" > newbie_test.txt +Test 12: Increment +&v.count &= 0 +&v.count++ +&v.count++ +&if &math &v.count = 2 &write Test 12 PASSED: Increment works, count = &v.count &to test_results.txt +&if ¬ &math &v.count = 2 &write Test 12 FAILED: Increment failed, count = &v.count &to test_results.txt -Test 13: Show the created file -&show newbie_test.txt +Test 13: Decrement +&v.count-- +&if &math &v.count = 1 &write Test 13 PASSED: Decrement works, count = &v.count &to test_results.txt +&if ¬ &math &v.count = 1 &write Test 13 FAILED: Decrement failed, count = &v.count &to test_results.txt + +Test 14: Parentheses +&v.result &= &math (3 + 2) * 4 +&if &math &v.result = 20 &write Test 14 PASSED: (3+2)*4 = &v.result &to test_results.txt +&if ¬ &math &v.result = 20 &write Test 14 FAILED: (3+2)*4 should equal 20, got &v.result &to test_results.txt ============================================================================ -PHASE 4: Compression (Currently Working) +PHASE 4: Conditionals ============================================================================ -Test 14: Create and compress a test file -&run &bash echo "Test data for compression" > /tmp/test_source.txt -&convert test_source.txt &into test_compressed.gz -Test 15: Show compressed file (should decompress transparently) -&show test_compressed.gz +&write PHASE 4: Conditionals &to test_results.txt +&write ---------------------------------------- &to test_results.txt + +Test 15: Check file exists +&if &exists _test_file.txt &write Test 15 PASSED: File exists check &to test_results.txt +&if ¬ &exists _test_file.txt &write Test 15 FAILED: File exists check &to test_results.txt + +Test 16: Check file not exists +&if ¬ &exists _nonexistent_file.txt &write Test 16 PASSED: File not exists check &to test_results.txt +&if &exists _nonexistent_file.txt &write Test 16 FAILED: File not exists check &to test_results.txt + +Test 17: Math conditional greater than +&v.score &= 75 +&if &math &v.score > 60 &write Test 17 PASSED: Math greater than (75 > 60) &to test_results.txt +&if ¬ &math &v.score > 60 &write Test 17 FAILED: Math greater than (75 > 60) &to test_results.txt -Test 16: Convert between compression formats -&convert test_compressed.gz &into test_compressed.bz2 +Test 18: Math conditional less than +&if &math &v.score < 90 &write Test 18 PASSED: Math less than (75 < 90) &to test_results.txt +&if ¬ &math &v.score < 90 &write Test 18 FAILED: Math less than (75 < 90) &to test_results.txt + +Test 19: Negated math conditional +&if ¬ &math &v.score > 100 &write Test 19 PASSED: Negated math (NOT 75 > 100) &to test_results.txt +&if &math &v.score > 100 &write Test 19 FAILED: Negated math (NOT 75 > 100) &to test_results.txt ============================================================================ -PHASE 5: File Operations (Currently Working) +PHASE 5: Pattern Matching ============================================================================ -Test 17: Copy a file -© newbie_test.txt &to newbie_test_copy.txt -Test 18: Move/rename a file -&move newbie_test_copy.txt &to newbie_test_renamed.txt +&write PHASE 5: Pattern Matching &to test_results.txt +&write ---------------------------------------- &to test_results.txt + +Test 20: Create pattern test file +&write Error 123 occurred &to _pattern_test.txt +&write Warning: low memory &to _pattern_test.txt +&write Error 456 critical failure &to _pattern_test.txt +&write Status OK &to _pattern_test.txt +&write ID-ABC1234 record &to _pattern_test.txt +&if &exists _pattern_test.txt &write Test 20 PASSED: Pattern test file created &to test_results.txt + +Test 21: Find literal pattern +&find Error &in _pattern_test.txt &into _find_result.txt +&v.size &= &files &size _find_result.txt +&if &math &v.size > 0 &write Test 21 PASSED: Found literal pattern 'Error' &to test_results.txt +&if ¬ &math &v.size > 0 &write Test 21 FAILED: Literal pattern not found &to test_results.txt +&delete _find_result.txt + +Test 22: Find with &numbers +&find Error &numbers &in _pattern_test.txt &into _find_result.txt +&v.size &= &files &size _find_result.txt +&if &math &v.size > 0 &write Test 22 PASSED: Found pattern with &numbers &to test_results.txt +&if ¬ &math &v.size > 0 &write Test 22 FAILED: Pattern with &numbers not found &to test_results.txt +&delete _find_result.txt -Test 19: Show the moved file -&show newbie_test_renamed.txt +Test 23: Find with &letters and fixed-length &numbers +&find ID- &letters 3 &+ &numbers 4 &in _pattern_test.txt &into _find_result.txt +&v.size &= &files &size _find_result.txt +&if &math &v.size > 0 &write Test 23 PASSED: Found ID-ABC1234 pattern &to test_results.txt +&if ¬ &math &v.size > 0 &write Test 23 FAILED: ID pattern not found &to test_results.txt +&delete _find_result.txt + +Test 24: Find with &start anchor +&find &start &= Error &in _pattern_test.txt &into _find_result.txt +&v.size &= &files &size _find_result.txt +&if &math &v.size > 0 &write Test 24 PASSED: Found lines starting with Error &to test_results.txt +&if ¬ &math &v.size > 0 &write Test 24 FAILED: Start anchor pattern not found &to test_results.txt +&delete _find_result.txt ============================================================================ -PHASE 6: Variable System (Currently Working) +PHASE 6: UTF-8 and International Text (&text) ============================================================================ -Test 20: Set and get a variable -&v.testvar &= Hello from Newbie -&show &v.testvar -Test 21: List all variables -&vars +&write PHASE 6: UTF-8 and International Text &to test_results.txt +&write ---------------------------------------- &to test_results.txt + +Test 25: Create UTF-8 test file +&write Name: José García &to _utf8_test.txt +&write 名前: 田中太郎 &to _utf8_test.txt +&write Имя: Иван Петров &to _utf8_test.txt +&write Code: ABCD1234 &to _utf8_test.txt +&if &exists _utf8_test.txt &write Test 25 PASSED: UTF-8 test file created &to test_results.txt + +Test 26: Find with &text (variable length) +&find Name: &+ &text &in _utf8_test.txt &into _find_result.txt +&v.size &= &files &size _find_result.txt +&if &math &v.size > 0 &write Test 26 PASSED: Found pattern with &text &to test_results.txt +&if ¬ &math &v.size > 0 &write Test 26 FAILED: &text pattern not found &to test_results.txt +&delete _find_result.txt -Test 22: Show system variables -&vars system +Test 27: Find with &text fixed length +&find Code: &+ &text 8 &in _utf8_test.txt &into _find_result.txt +&v.size &= &files &size _find_result.txt +&if &math &v.size > 0 &write Test 27 PASSED: Found pattern with &text 8 &to test_results.txt +&if ¬ &math &v.size > 0 &write Test 27 FAILED: Fixed-length &text pattern not found &to test_results.txt +&delete _find_result.txt ============================================================================ -PHASE 7: Block Processing +PHASE 7: Block Processing ============================================================================ -Test 23: Simple line-by-line processing -Note that &newbie.line is always populated by the current line in the block -&block newbie_test.txt - &empty &v.line - &v.line &= &newbie.line - &write Line: &v.line &to processed.txt -&endblock -Test 24: Pattern capture and conditional output -&block newbie_test.txt - &empty &v.num - &v.num &= 56 - &if &v.num &write Found number: &v.num &to filtered.txt +&write PHASE 7: Block Processing &to test_results.txt +&write ---------------------------------------- &to test_results.txt + +Test 28: Simple block processing +&v.linecount &= 0 +&block _test_file.txt + &v.linecount++ &endblock +&if &math &v.linecount = 5 &write Test 28 PASSED: Block processed 5 lines &to test_results.txt +&if ¬ &math &v.linecount = 5 &write Test 28 FAILED: Block linecount = &v.linecount (expected 5) &to test_results.txt -Test 25: Multiple output files -&block newbie_test.txt - &empty &v.line &v.num - &v.line &= &newbie.line - &v.num &= 24 - &write &v.line &to lines.txt - &if &v.num &write &v.num &to numbers.txt +Test 29: Block with capture +&write ID: 12345, Name: TestUser &to _capture_test.txt +&write ID: 67890, Name: AnotherUser &to _capture_test.txt +&v.capturecount &= 0 +&block _capture_test.txt + &empty &v.id &v.name + &capture ID: &+ &v.id &+ , Name: &+ &v.name + &if &v.id &filled &v.capturecount++ &endblock +&if &math &v.capturecount = 2 &write Test 29 PASSED: Captured 2 records &to test_results.txt +&if ¬ &math &v.capturecount = 2 &write Test 29 FAILED: Capture count = &v.capturecount (expected 2) &to test_results.txt -Test 26: Accumulation with &append -&block newbie_test.txt - &empty &v.line - &v.line &= &newbie.line - &write &v.line &to accumulated.txt +Test 30: Block with &files (multiple files) +&create _multitest/ +&write File A content &to _multitest/a.txt +&write File B content &to _multitest/b.txt +&v.filelinecount &= 0 +&block &files _multitest/ + &v.filelinecount++ &endblock +&if &math &v.filelinecount = 2 &write Test 30 PASSED: Processed 2 files &to test_results.txt +&if ¬ &math &v.filelinecount = 2 &write Test 30 FAILED: File line count = &v.filelinecount (expected 2) &to test_results.txt + +============================================================================ +PHASE 8: Compression +============================================================================ + + +&write PHASE 8: Compression &to test_results.txt +&write ---------------------------------------- &to test_results.txt + +Test 31: Convert to gzip +&write Test data for compression testing &to _compress_source.txt +&convert _compress_source.txt &into _compressed.gz +&if &exists _compressed.gz &write Test 31 PASSED: Converted to gzip &to test_results.txt +&if ¬ &exists _compressed.gz &write Test 31 FAILED: Gzip conversion failed &to test_results.txt + +Test 32: Convert gzip to bzip2 +&if &exists _compressed.gz &convert _compressed.gz &into _compressed.bz2 +&if &exists _compressed.bz2 &write Test 32 PASSED: Converted gzip to bzip2 &to test_results.txt +&if ¬ &exists _compressed.bz2 &write Test 32 FAILED: Bzip2 conversion failed &to test_results.txt + +Test 33: Find in compressed file +&if &exists _compressed.gz &find compression &in _compressed.gz &into _compressed_find.txt +&if &exists _compressed_find.txt &v.size &= &files &size _compressed_find.txt +&if &math &v.size > 0 &write Test 33 PASSED: Found pattern in compressed file &to test_results.txt +&if ¬ &math &v.size > 0 &write Test 33 FAILED: Pattern not found in compressed file &to test_results.txt + +============================================================================ +PHASE 9: Lookup Tables +============================================================================ + + +&write PHASE 9: Lookup Tables &to test_results.txt +&write ---------------------------------------- &to test_results.txt + +Test 34: Create and use lookup table +&run &bash printf 'apple\tfruit\ncarrot\tvegetable\n' > _lookup_dict.txt +&write I like apple and carrot &to _lookup_input.txt +&lookup _lookup_dict.txt &in _lookup_input.txt &into _lookup_output.txt +&find fruit &in _lookup_output.txt &into _lookup_verify.txt +&v.size &= &files &size _lookup_verify.txt +&if &math &v.size > 0 &write Test 34 PASSED: Lookup table replaced values &to test_results.txt +&if ¬ &math &v.size > 0 &write Test 34 FAILED: Lookup replacement failed &to test_results.txt + +============================================================================ +PHASE 10: Sorting +============================================================================ + + +&write PHASE 10: Sorting &to test_results.txt +&write ---------------------------------------- &to test_results.txt + +Test 35: Sort file +&write zebra &to _sort_input.txt +&write apple &to _sort_input.txt +&write mango &to _sort_input.txt +&sort _sort_input.txt &into _sort_output.txt +&if &exists _sort_output.txt &write Test 35 PASSED: File sorted successfully &to test_results.txt +&if ¬ &exists _sort_output.txt &write Test 35 FAILED: Sort failed &to test_results.txt + +============================================================================ +PHASE 11: File Size +============================================================================ + + +&write PHASE 11: File Size &to test_results.txt +&write ---------------------------------------- &to test_results.txt + +Test 36: Get file size +&v.size &= &files &size _test_file.txt +&if &math &v.size > 0 &write Test 36 PASSED: File size is &v.size bytes &to test_results.txt +&if ¬ &math &v.size > 0 &write Test 36 FAILED: Could not get file size &to test_results.txt + +============================================================================ +CLEANUP - Remove all temporary files +============================================================================ + + +&write ============================================================================ &to test_results.txt +&write Cleanup: Removing temporary files &to test_results.txt +&write ============================================================================ &to test_results.txt + +Cleanup Phase 1 files +&delete _test_file.txt +&delete _test_renamed.txt +&delete _testdir/ + +Cleanup Phase 5 files +&delete _pattern_test.txt + +Cleanup Phase 6 files +&delete _utf8_test.txt + +Cleanup Phase 7 files +&delete _capture_test.txt +&delete _multitest/ + +Cleanup Phase 8 files +&delete _compress_source.txt +&delete _compressed.gz +&delete _compressed.bz2 +&delete _compressed_find.txt + +Cleanup Phase 9 files +&delete _lookup_dict.txt +&delete _lookup_input.txt +&delete _lookup_output.txt +&delete _lookup_verify.txt + +Cleanup Phase 10 files +&delete _sort_input.txt +&delete _sort_output.txt + +&write Cleanup complete &to test_results.txt + +&write ============================================================================ &to test_results.txt +&write All tests completed: &system.date &system.time &to test_results.txt +&write Results saved to: test_results.txt &to test_results.txt +&write ============================================================================ &to test_results.txt -# ============================================================================ -# CLEANUP -# ============================================================================ - -# Test 27: Delete test files -&delete newbie_test.txt -&delete test_source.txt -&delete test_compressed.gz -&delete newbie_test_renamed.txt -&delete processed.txt -&delete filtered.txt -&delete lines.txt -&delete numbers.txt -&delete accumulated.txt +&write Test suite complete. Results saved to test_results.txt &to &display @@ -645,3 +862,3 @@ --- -Newbie 1.0 - ©2025 Mark Allen Battey - MIT License +Newbie 1.0.7 - ©2025 Mark Allen Battey - MIT License Only in /var/lib/copr-rpmbuild/results/newbie/upstream-unpacked/Source0/Newbie-1.0.8: main.rs diff -U2 -r /var/lib/copr-rpmbuild/results/newbie/upstream-unpacked/Source0/Newbie-1.0.8/newbie.spec /var/lib/copr-rpmbuild/results/newbie/srpm-unpacked/Newbie-1.0.8.tar.gz-extract/Newbie-1.0.8/newbie.spec --- /var/lib/copr-rpmbuild/results/newbie/upstream-unpacked/Source0/Newbie-1.0.8/newbie.spec 2025-12-14 18:14:52.000000000 +0000 +++ /var/lib/copr-rpmbuild/results/newbie/srpm-unpacked/Newbie-1.0.8.tar.gz-extract/Newbie-1.0.8/newbie.spec 2025-12-27 18:01:57.000000000 +0000 @@ -6,5 +6,5 @@ Name: newbie -Version: 1.0.6 +Version: 1.0.8 Release: %autorelease Summary: Natural language shell interpreter and text processor Only in /var/lib/copr-rpmbuild/results/newbie/srpm-unpacked/Newbie-1.0.8.tar.gz-extract/Newbie-1.0.8/src: main.1216 Only in /var/lib/copr-rpmbuild/results/newbie/srpm-unpacked/Newbie-1.0.8.tar.gz-extract/Newbie-1.0.8/src: main.1217 Only in /var/lib/copr-rpmbuild/results/newbie/srpm-unpacked/Newbie-1.0.8.tar.gz-extract/Newbie-1.0.8/src: main.1218 Only in /var/lib/copr-rpmbuild/results/newbie/srpm-unpacked/Newbie-1.0.8.tar.gz-extract/Newbie-1.0.8/src: main.1226 diff -U2 -r /var/lib/copr-rpmbuild/results/newbie/upstream-unpacked/Source0/Newbie-1.0.8/src/main.rs /var/lib/copr-rpmbuild/results/newbie/srpm-unpacked/Newbie-1.0.8.tar.gz-extract/Newbie-1.0.8/src/main.rs --- /var/lib/copr-rpmbuild/results/newbie/upstream-unpacked/Source0/Newbie-1.0.8/src/main.rs 2025-12-14 18:14:52.000000000 +0000 +++ /var/lib/copr-rpmbuild/results/newbie/srpm-unpacked/Newbie-1.0.8.tar.gz-extract/Newbie-1.0.8/src/main.rs 2025-12-26 16:24:52.000000000 +0000 @@ -28,22 +28,24 @@ extern crate libc; -// Sleep inhibitor helper -// Returns a Child process that must be kept alive to maintain the inhibitor lock -// When the Child is dropped (or killed), the inhibitor is released -fn create_sleep_inhibitor() -> Option { - // Spawn systemd-inhibit with a long-running sleep command - // This prevents system sleep/suspend while the process is alive - StdCommand::new("systemd-inhibit") - .arg("--what=sleep:idle") - .arg("--who=newbie") - .arg("--why=Processing files") - .arg("--mode=block") - .arg("sleep") - .arg("infinity") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .ok() +// Sleep inhibitor using D-Bus (org.freedesktop.login1) +// Holds a file descriptor from logind - inhibitor releases when fd closes +use zbus::zvariant::OwnedFd; + +#[allow(dead_code)] +struct SleepInhibitor(Option); + +fn create_sleep_inhibitor() -> SleepInhibitor { + let fd = (|| -> Option { + let conn = zbus::blocking::Connection::system().ok()?; + let proxy = zbus::blocking::Proxy::new( + &conn, + "org.freedesktop.login1", + "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", + ).ok()?; + + proxy.call("Inhibit", &("sleep:idle", "newbie", "Processing files", "block")).ok() + })(); + SleepInhibitor(fd) } @@ -85,5 +87,8 @@ let start = line[..pos] .rfind(|c: char| c.is_whitespace() || c == '&') - .map(|i| i + 1) + .map(|i| { + // Keep the & but skip whitespace + if line.as_bytes()[i] == b'&' { i } else { i + 1 } + }) .unwrap_or(0); @@ -93,11 +98,22 @@ if prefix.starts_with('&') { let keywords = [ - "&show", "&find", "&capture", "©", "&move", "&delete", "&run", "&admin", - "&into", "&to", "&in", "&from", "&first", "&last", "&lines", - "&chars", "&numbered", "&raw", "&all", "&start", "&end", - "&numbers", "&letters", "&space", "&tab", "&empty", "&trim", - "&write", "&block", "&endblock", "&lookup", "&vars", "&exit", "&license", - "&if", "¬", "&wrap", "&guide", "&bash", - "&v.", "&system.", "&config.", "&newbie.", + // Commands + "&show", "&find", "&capture", "&match", "©", "&move", "&delete", "&create", + "&run", "&admin", "&convert", "&sort", "&lookup", "&unicode", + // Modifiers + "&into", "&to", "&in", "&first", "&last", "&numbered", "&original_numbers", + "&raw", "&all", "&directory", + // Pattern keywords + "&start", "&end", "&numbers", "&letters", "&text", + "&space", "&spaces", "&tab", "&tabs", "&+", "&=", + // Conditionals and control + "&write", "&block", "&endblock", "&if", "¬", "&exists", + "&empty", "&filled", "&math", + // Utility + "&vars", "&exit", "&license", "&wrap", "&guide", "&bash", + // Special targets + "&files", "&size", "&display", "&pipeline", + // Variable namespaces + "&v.", "&system.", "&process.", "&network.", "&global.", "&config.", "&newbie.", ]; @@ -252,6 +268,7 @@ enum PatternComponent { Literal([u8; MAX_COMPONENT_TEXT], usize), - Numbers(usize), - Letters(usize), + Numbers(usize), // Match n digits (0 = one or more) + Letters(usize), // Match n ASCII letters (0 = one or more) + Text(usize), // Match n characters (any UTF-8 chars, 0 = one or more) Space(usize), Tab(usize), @@ -326,4 +343,5 @@ pub wrap_lines: bool, pub scripting_mode: bool, + pub files_size_only: bool, } @@ -359,4 +377,5 @@ wrap_lines: wrap_enabled, scripting_mode: false, + files_size_only: false, } } @@ -381,4 +400,5 @@ KeywordEntry { name: "&move", handler: handle_move }, KeywordEntry { name: "&delete", handler: handle_delete }, + KeywordEntry { name: "&create", handler: handle_create }, KeywordEntry { name: "&run", handler: handle_run }, KeywordEntry { name: "&first", handler: handle_first }, @@ -390,6 +410,4 @@ KeywordEntry { name: "&into", handler: handle_into }, KeywordEntry { name: "&admin", handler: handle_admin }, - KeywordEntry { name: "&set", handler: handle_set }, - KeywordEntry { name: "&get", handler: handle_get }, KeywordEntry { name: "&vars", handler: handle_vars }, KeywordEntry { name: "&convert", handler: handle_convert }, @@ -403,8 +421,10 @@ KeywordEntry { name: "&lookup", handler: handle_lookup }, KeywordEntry { name: "&sort", handler: handle_sort }, + KeywordEntry { name: "&unicode", handler: handle_unicode }, KeywordEntry { name: "&if", handler: handle_if }, - KeywordEntry { name: "¬", handler: handle_not }, KeywordEntry { name: "&wrap", handler: handle_wrap }, KeywordEntry { name: "&guide", handler: handle_guide }, + // Note: ¬, &exists, &math are parsed inside &if, not as standalone keywords + // Note: &size, &display, &pipeline are special targets, not command keywords ]; @@ -496,5 +516,356 @@ } +// ============================================================================ +// MATH EVALUATION (v1.02) +// Supports: +, -, *, /, and comparisons <, >, = +// Only used in variable assignments: &v.x &= &math expression +// ============================================================================ + +/// Token types for math expression parsing +#[derive(Debug, Clone, PartialEq)] +enum MathToken { + Number(f64), + Plus, + Minus, + Multiply, + Divide, + LessThan, + GreaterThan, + Equals, + LParen, + RParen, +} + +/// Tokenize a math expression string into tokens +fn tokenize_math(expr: &str) -> Result, Box> { + let mut tokens = Vec::new(); + let chars: Vec = expr.chars().collect(); + let mut i = 0; + + while i < chars.len() { + let ch = chars[i]; + + // Skip whitespace + if ch.is_whitespace() { + i += 1; + continue; + } + + // Number (including negative numbers at start or after operator) + if ch.is_ascii_digit() || ch == '.' || + (ch == '-' && (tokens.is_empty() || matches!(tokens.last(), + Some(MathToken::Plus) | Some(MathToken::Minus) | + Some(MathToken::Multiply) | Some(MathToken::Divide) | + Some(MathToken::LessThan) | Some(MathToken::GreaterThan) | + Some(MathToken::Equals) | Some(MathToken::LParen)))) { + let start = i; + if ch == '-' { + i += 1; + } + while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') { + i += 1; + } + let num_str: String = chars[start..i].iter().collect(); + let num: f64 = num_str.parse() + .map_err(|_| NewbieError::new(&format!("Invalid number: {}", num_str)))?; + tokens.push(MathToken::Number(num)); + continue; + } + + // Operators + match ch { + '+' => tokens.push(MathToken::Plus), + '-' => tokens.push(MathToken::Minus), + '*' => tokens.push(MathToken::Multiply), + '/' => tokens.push(MathToken::Divide), + '<' => tokens.push(MathToken::LessThan), + '>' => tokens.push(MathToken::GreaterThan), + '=' => tokens.push(MathToken::Equals), + '(' => tokens.push(MathToken::LParen), + ')' => tokens.push(MathToken::RParen), + _ => return Err(NewbieError::new(&format!("Unknown character in math expression: {}", ch))), + } + i += 1; + } + + Ok(tokens) +} + +/// Recursive descent parser for math expressions +/// Handles operator precedence: comparisons < addition/subtraction < multiplication/division +struct MathParser { + tokens: Vec, + pos: usize, +} + +impl MathParser { + fn new(tokens: Vec) -> Self { + MathParser { tokens, pos: 0 } + } + + fn peek(&self) -> Option<&MathToken> { + self.tokens.get(self.pos) + } + + fn consume(&mut self) -> Option { + if self.pos < self.tokens.len() { + let token = self.tokens[self.pos].clone(); + self.pos += 1; + Some(token) + } else { + None + } + } + + /// Parse full expression (lowest precedence: comparisons) + fn parse_expression(&mut self) -> Result> { + let mut left = self.parse_additive()?; + + while let Some(token) = self.peek() { + match token { + MathToken::LessThan => { + self.consume(); + let right = self.parse_additive()?; + left = if left < right { 1.0 } else { 0.0 }; + } + MathToken::GreaterThan => { + self.consume(); + let right = self.parse_additive()?; + left = if left > right { 1.0 } else { 0.0 }; + } + MathToken::Equals => { + self.consume(); + let right = self.parse_additive()?; + // Use epsilon comparison for floating point + left = if (left - right).abs() < 1e-10 { 1.0 } else { 0.0 }; + } + _ => break, + } + } + + Ok(left) + } + + /// Parse additive expressions (+ and -) + fn parse_additive(&mut self) -> Result> { + let mut left = self.parse_multiplicative()?; + + while let Some(token) = self.peek() { + match token { + MathToken::Plus => { + self.consume(); + let right = self.parse_multiplicative()?; + left += right; + } + MathToken::Minus => { + self.consume(); + let right = self.parse_multiplicative()?; + left -= right; + } + _ => break, + } + } + + Ok(left) + } + + /// Parse multiplicative expressions (* and /) + fn parse_multiplicative(&mut self) -> Result> { + let mut left = self.parse_primary()?; + + while let Some(token) = self.peek() { + match token { + MathToken::Multiply => { + self.consume(); + let right = self.parse_primary()?; + left *= right; + } + MathToken::Divide => { + self.consume(); + let right = self.parse_primary()?; + if right == 0.0 { + return Err(NewbieError::new("Division by zero")); + } + left /= right; + } + _ => break, + } + } + + Ok(left) + } + + /// Parse primary expressions (numbers and parenthesized expressions) + fn parse_primary(&mut self) -> Result> { + match self.consume() { + Some(MathToken::Number(n)) => Ok(n), + Some(MathToken::LParen) => { + let result = self.parse_expression()?; + match self.consume() { + Some(MathToken::RParen) => Ok(result), + _ => Err(NewbieError::new("Missing closing parenthesis")), + } + } + Some(token) => Err(NewbieError::new(&format!("Unexpected token: {:?}", token))), + None => Err(NewbieError::new("Unexpected end of expression")), + } + } +} + +/// Evaluate a math expression string, expanding variables first +/// Returns the result as a string (integer if whole number, otherwise float) +fn evaluate_math_expression(expr: &str) -> Result> { + // First, expand any variable references in the expression + let expanded = expand_variables_in_math(expr)?; + + // Tokenize + let tokens = tokenize_math(&expanded)?; + + if tokens.is_empty() { + return Err(NewbieError::new("Empty math expression")); + } + + // Parse and evaluate + let mut parser = MathParser::new(tokens); + let result = parser.parse_expression()?; + + // Check if there are unconsumed tokens + if parser.pos < parser.tokens.len() { + return Err(NewbieError::new("Unexpected tokens after expression")); + } + + // Format result: integer if whole number, otherwise float + if result.fract() == 0.0 && result.abs() < i64::MAX as f64 { + Ok(format!("{}", result as i64)) + } else { + Ok(format!("{}", result)) + } +} + +/// Expand variable references in a math expression +fn expand_variables_in_math(expr: &str) -> Result> { + let mut result = String::new(); + let mut chars = expr.char_indices().peekable(); + + while let Some((pos, ch)) = chars.next() { + // Check for variable reference starting with & + if ch == '&' { + let var_start = pos; + let mut var_end = pos + 1; + + // Scan variable name (alphanumeric, dots, underscores) + let bytes = expr.as_bytes(); + while var_end < bytes.len() { + let b = bytes[var_end]; + if b.is_ascii_alphanumeric() || b == b'.' || b == b'_' { + var_end += 1; + } else { + break; + } + } + + if var_end > var_start + 1 { + let var_ref = &expr[var_start..var_end]; + if let Some((namespace, name)) = parse_variable_reference(var_ref) { + if let Some(value) = get_variable(namespace, &name) { + // Verify the value is numeric + if value.parse::().is_ok() { + result.push_str(&value); + // Skip chars we've consumed + while let Some((i, _)) = chars.peek() { + if *i < var_end { + chars.next(); + } else { + break; + } + } + continue; + } else { + return Err(NewbieError::new(&format!( + "Variable {} contains non-numeric value: {}", var_ref, value))); + } + } else { + return Err(NewbieError::new(&format!( + "Undefined variable in math expression: {}", var_ref))); + } + } + } + } + + // Regular character + result.push(ch); + } + + Ok(result) +} + +/// Try to parse and handle increment/decrement operators (&v.varname++ or &v.varname--) +/// Returns Some(()) if handled, None if not an increment/decrement operation +fn try_handle_increment_decrement(line: &str) -> Result, Box> { + let trimmed = line.trim(); + + // Check for ++ at the end + if trimmed.ends_with("++") { + let var_part = &trimmed[..trimmed.len() - 2]; + if let Some((namespace, name)) = parse_variable_reference(var_part) { + if matches!(namespace, + VariableNamespace::User | + VariableNamespace::Global | + VariableNamespace::Config + ) { + // Get current value, parse as number, increment, store + let current = get_variable(namespace.clone(), &name) + .unwrap_or_else(|| "0".to_string()); + let num: f64 = current.parse() + .map_err(|_| NewbieError::new(&format!( + "Cannot increment non-numeric value: {}", current)))?; + let new_val = num + 1.0; + let new_str = if new_val.fract() == 0.0 { + format!("{}", new_val as i64) + } else { + format!("{}", new_val) + }; + set_variable(namespace, &name, &new_str)?; + return Ok(Some(())); + } + } + } + + // Check for -- at the end + if trimmed.ends_with("--") { + let var_part = &trimmed[..trimmed.len() - 2]; + if let Some((namespace, name)) = parse_variable_reference(var_part) { + if matches!(namespace, + VariableNamespace::User | + VariableNamespace::Global | + VariableNamespace::Config + ) { + // Get current value, parse as number, decrement, store + let current = get_variable(namespace.clone(), &name) + .unwrap_or_else(|| "0".to_string()); + let num: f64 = current.parse() + .map_err(|_| NewbieError::new(&format!( + "Cannot decrement non-numeric value: {}", current)))?; + let new_val = num - 1.0; + let new_str = if new_val.fract() == 0.0 { + format!("{}", new_val as i64) + } else { + format!("{}", new_val) + }; + set_variable(namespace, &name, &new_str)?; + return Ok(Some(())); + } + } + } + + Ok(None) +} + fn get_reader(path: &str) -> Result, Box> { + // Handle &pipeline - read from stdin + if path == "&pipeline" { + return Ok(Box::new(BufReader::new(std::io::stdin()))); + } + let mut file = File::open(path)?; let mut buf = [0u8; 6]; @@ -537,4 +908,104 @@ } +/// Log bad line bytes to badlines.log in the current directory. +/// Shows the raw bytes as hex when UTF-8 conversion fails. +fn log_bad_line_bytes(bytes: &[u8]) { + if let Ok(mut file) = OpenOptions::new() + .create(true) + .append(true) + .open("badlines.log") + { + // Write as hex dump for debugging + let hex: String = bytes.iter().map(|b| format!("{:02x} ", b)).collect(); + let _ = writeln!(file, "Invalid UTF-8 ({} bytes): {}", bytes.len(), hex); + // Also try to show printable portion + let printable: String = bytes.iter() + .map(|&b| if b >= 0x20 && b < 0x7f { b as char } else { '.' }) + .collect(); + let _ = writeln!(file, "Printable: {}", printable); + } +} + +/// Read a line into the provided fixed buffer. Returns the number of bytes read (excluding newline). +/// Returns None on EOF, Some(len) on success. +fn read_line_into_buffer(reader: &mut dyn BufRead, buffer: &mut [u8; MAX_LINE_SIZE]) -> Option { + let mut pos = 0; + + loop { + let available = match reader.fill_buf() { + Ok(buf) => buf, + Err(_) => return if pos == 0 { None } else { Some(pos) }, + }; + + if available.is_empty() { + // EOF + return if pos == 0 { None } else { Some(pos) }; + } + + // Find newline in available buffer + if let Some(newline_pos) = available.iter().position(|&b| b == b'\n') { + // Copy up to newline into our buffer + let copy_len = std::cmp::min(newline_pos, MAX_LINE_SIZE - pos); + buffer[pos..pos + copy_len].copy_from_slice(&available[..copy_len]); + pos += copy_len; + reader.consume(newline_pos + 1); // +1 to skip the newline + break; + } else { + // No newline yet, copy what we have and continue + let copy_len = std::cmp::min(available.len(), MAX_LINE_SIZE - pos); + buffer[pos..pos + copy_len].copy_from_slice(&available[..copy_len]); + pos += copy_len; + let consumed = available.len(); + reader.consume(consumed); + + if pos >= MAX_LINE_SIZE { + // Buffer full, truncate line + break; + } + } + } + + // Strip trailing carriage return + if pos > 0 && buffer[pos - 1] == b'\r' { + pos -= 1; + } + + Some(pos) +} + +/// Returns an iterator that yields only valid UTF-8 lines, logging bad ones to badlines.log. +fn tolerant_lines(reader: Box) -> impl Iterator { + TolerantLines { + reader, + buffer: [0u8; MAX_LINE_SIZE], + } +} + +struct TolerantLines { + reader: Box, + buffer: [u8; MAX_LINE_SIZE], +} + +impl Iterator for TolerantLines { + type Item = String; + + fn next(&mut self) -> Option { + loop { + match read_line_into_buffer(&mut *self.reader, &mut self.buffer) { + None => return None, + Some(len) => { + match std::str::from_utf8(&self.buffer[..len]) { + Ok(s) => return Some(s.to_string()), + Err(_) => { + log_bad_line_bytes(&self.buffer[..len]); + continue; + } + } + } + } + } + } +} + fn get_system_variable(name: &str) -> Option { match name { @@ -773,4 +1244,5 @@ PatternComponent::Numbers(n) => *n, PatternComponent::Letters(n) => *n, + PatternComponent::Text(n) => *n, // Note: this is character count, not bytes PatternComponent::Space(n) => *n, PatternComponent::Tab(n) => *n, @@ -779,4 +1251,45 @@ } +// Helper function to get byte length of n UTF-8 characters starting at byte position +fn utf8_chars_byte_length(line_bytes: &[u8], start_pos: usize, char_count: usize) -> usize { + if let Ok(s) = std::str::from_utf8(&line_bytes[start_pos..]) { + let mut byte_len = 0; + for (i, ch) in s.chars().enumerate() { + if i >= char_count { + break; + } + byte_len += ch.len_utf8(); + } + byte_len + } else { + // Invalid UTF-8, fall back to byte count + std::cmp::min(char_count, line_bytes.len() - start_pos) + } +} + +// Helper function to count consecutive non-whitespace characters and return byte length +fn count_text_run_bytes(line_bytes: &[u8], start_pos: usize) -> usize { + if let Ok(s) = std::str::from_utf8(&line_bytes[start_pos..]) { + let mut byte_len = 0; + for ch in s.chars() { + if ch.is_whitespace() { + break; + } + byte_len += ch.len_utf8(); + } + byte_len + } else { + // Invalid UTF-8, count non-whitespace bytes + let mut len = 0; + for &byte in &line_bytes[start_pos..] { + if byte == b' ' || byte == b'\t' || byte == b'\n' || byte == b'\r' { + break; + } + len += 1; + } + len + } +} + fn find_component_in_line_bytes( line_bytes: &[u8], @@ -921,4 +1434,35 @@ } } + + PatternComponent::Text(n) => { + // Text matches any characters (UTF-8 aware) + // n=0 means greedy match to next component, n>0 means exactly n characters + + if *n == 0 { + // Greedy text - can start at any position (including whitespace) + // The actual length is determined by looking ahead to the next component + if search_start < line_bytes.len() { + return Some(search_start); + } + None + } else { + // Match exactly n characters + // Check if there are at least n characters from search_start + if let Ok(line_str) = std::str::from_utf8(&line_bytes[search_start..]) { + let char_count = line_str.chars().count(); + if char_count >= *n { + return Some(search_start); + } + None + } else { + // Invalid UTF-8, fall back to byte count + if search_start + n <= line_bytes.len() { + return Some(search_start); + } + None + } + } + } + PatternComponent::Variable(var_name) => { // For &find (non-capture mode), expand the variable and match its literal value @@ -944,4 +1488,166 @@ } +/// Check if a component matches at an exact position (no searching). +/// Returns the byte length of the match if successful, None otherwise. +/// This is O(component_length) instead of O(line_length). +fn check_component_at_position( + line_bytes: &[u8], + component: &PatternComponent, + pos: usize +) -> Option { + if pos >= line_bytes.len() { + return None; + } + + match component { + PatternComponent::Literal(buffer, len) => { + if pos + len > line_bytes.len() { + return None; + } + let pattern = &buffer[..*len]; + if &line_bytes[pos..pos + len] == pattern { + Some(*len) + } else { + None + } + } + + PatternComponent::Numbers(n) => { + if *n == 0 { + // Greedy: count consecutive digits + if !line_bytes[pos].is_ascii_digit() { + return None; + } + let mut len = 0; + while pos + len < line_bytes.len() && line_bytes[pos + len].is_ascii_digit() { + len += 1; + } + Some(len) + } else { + // Exact count + if pos + n > line_bytes.len() { + return None; + } + for i in 0..*n { + if !line_bytes[pos + i].is_ascii_digit() { + return None; + } + } + Some(*n) + } + } + + PatternComponent::Letters(n) => { + if *n == 0 { + // Greedy: count consecutive letters + if !line_bytes[pos].is_ascii_alphabetic() { + return None; + } + let mut len = 0; + while pos + len < line_bytes.len() && line_bytes[pos + len].is_ascii_alphabetic() { + len += 1; + } + Some(len) + } else { + // Exact count + if pos + n > line_bytes.len() { + return None; + } + for i in 0..*n { + if !line_bytes[pos + i].is_ascii_alphabetic() { + return None; + } + } + Some(*n) + } + } + + PatternComponent::Space(n) => { + if *n == 0 { + // Greedy: count consecutive spaces + if line_bytes[pos] != b' ' { + return None; + } + let mut len = 0; + while pos + len < line_bytes.len() && line_bytes[pos + len] == b' ' { + len += 1; + } + Some(len) + } else { + // Exact count + if pos + n > line_bytes.len() { + return None; + } + for i in 0..*n { + if line_bytes[pos + i] != b' ' { + return None; + } + } + Some(*n) + } + } + + PatternComponent::Tab(n) => { + if *n == 0 { + // Greedy: count consecutive tabs + if line_bytes[pos] != b'\t' { + return None; + } + let mut len = 0; + while pos + len < line_bytes.len() && line_bytes[pos + len] == b'\t' { + len += 1; + } + Some(len) + } else { + // Exact count + if pos + n > line_bytes.len() { + return None; + } + for i in 0..*n { + if line_bytes[pos + i] != b'\t' { + return None; + } + } + Some(*n) + } + } + + PatternComponent::Text(n) => { + if *n == 0 { + // Greedy text - will be handled by caller looking for next component + // Just confirm we're at a valid position + Some(0) // Length determined by next fence + } else { + // Exactly n characters + let byte_len = utf8_chars_byte_length(line_bytes, pos, *n); + if byte_len > 0 { + Some(byte_len) + } else { + None + } + } + } + + PatternComponent::Variable(var_name) => { + // For matching mode, expand variable and check if it matches at position + if let Some(value) = get_variable(VariableNamespace::User, var_name) { + let value_bytes = value.as_bytes(); + let value_len = value_bytes.len(); + + if pos + value_len > line_bytes.len() { + return None; + } + if &line_bytes[pos..pos + value_len] == value_bytes { + Some(value_len) + } else { + None + } + } else { + None + } + } + } +} + fn parse_anchor_assignment( @@ -1049,4 +1755,5 @@ "&numbers" => return Ok(PatternComponent::Numbers(n)), "&letters" => return Ok(PatternComponent::Letters(n)), + "&text" => return Ok(PatternComponent::Text(n)), "&spaces" => return Ok(PatternComponent::Space(n)), "&tabs" => return Ok(PatternComponent::Tab(n)), @@ -1063,4 +1770,5 @@ "&numbers" => return Ok(PatternComponent::Numbers(0)), "&letters" => return Ok(PatternComponent::Letters(0)), + "&text" => return Ok(PatternComponent::Text(0)), "&spaces" => return Ok(PatternComponent::Space(0)), "&tabs" => return Ok(PatternComponent::Tab(0)), @@ -1203,4 +1911,5 @@ "&numbers" => Some(PatternComponent::Numbers(n)), "&letters" => Some(PatternComponent::Letters(n)), + "&text" => Some(PatternComponent::Text(n)), "&spaces" => Some(PatternComponent::Space(n)), "&tabs" => Some(PatternComponent::Tab(n)), @@ -1231,4 +1940,35 @@ } + // Handle standalone pattern keywords (greedy mode, n=0) + if let Some(token) = tokens[tok_idx] { + let component_opt = match token { + "&numbers" => Some(PatternComponent::Numbers(0)), + "&letters" => Some(PatternComponent::Letters(0)), + "&text" => Some(PatternComponent::Text(0)), + "&spaces" => Some(PatternComponent::Space(0)), + "&tabs" => Some(PatternComponent::Tab(0)), + _ => None, + }; + + if let Some(component) = component_opt { + pattern.components[pattern.component_count] = Some(component); + + let is_adjacent = if tok_idx + 1 < token_count { + tokens[tok_idx + 1] == Some("&+") + } else { + false + }; + + pattern.adjacent_to_next[pattern.component_count] = is_adjacent; + pattern.component_count += 1; + + tok_idx += 1; + if is_adjacent { + tok_idx += 1; + } + continue; + } + } + let mut literal_tokens: [Option<&str>; MAX_TOKENS_PER_LINE] = [None; MAX_TOKENS_PER_LINE]; let mut lit_count = 0; @@ -1240,13 +1980,15 @@ } - if tok_idx + 1 < token_count { - if let Some(next_token) = tokens[tok_idx + 1] { - if next_token.parse::().is_ok() { - match token { - "&numbers" | "&letters" | "&spaces" | "&tabs" => break, - _ => {} - } + // Check if this token is a standalone pattern keyword + match token { + "&numbers" | "&letters" | "&text" | "&spaces" | "&tabs" => { + // If we've already collected literal tokens, break to process them first + if lit_count > 0 { + break; } + // Otherwise, this keyword starts a new component - break to let outer loop handle it + break; } + _ => {} } @@ -1292,58 +2034,118 @@ for mask_idx in 0..pattern.component_count { if let Some(ref component) = pattern.components[mask_idx] { - match find_component_in_line_bytes(line_bytes, component, line_pos) { - Some(match_pos) => { - if mask_idx == 0 && pattern.start_anchor && match_pos != 0 { - return false; + // Check if this component must be adjacent to previous + let must_be_adjacent = mask_idx > 0 && pattern.adjacent_to_next[mask_idx - 1]; + + if must_be_adjacent { + // Use O(component_length) check at exact position + match check_component_at_position(line_bytes, component, line_pos) { + Some(match_length) => { + // Handle greedy Text(0) - need to find next component + let actual_length = if matches!(component, PatternComponent::Text(0)) { + if mask_idx + 1 < pattern.component_count { + if let Some(ref next_component) = pattern.components[mask_idx + 1] { + if let Some(next_pos) = find_component_in_line_bytes(line_bytes, next_component, line_pos) { + if next_pos >= line_pos { + next_pos - line_pos + } else { + return false; + } + } else { + return false; + } + } else { + line_bytes.len() - line_pos + } + } else { + line_bytes.len() - line_pos + } + } else { + match_length + }; + + let match_end = line_pos + actual_length; + last_match_end = match_end; + line_pos = match_end; } - - if mask_idx > 0 && pattern.adjacent_to_next[mask_idx - 1] { - if match_pos != line_pos { + None => return false, + } + } else { + // Use O(line_length) search from current position + match find_component_in_line_bytes(line_bytes, component, line_pos) { + Some(match_pos) => { + if mask_idx == 0 && pattern.start_anchor && match_pos != 0 { return false; } - } - - let match_length = match component { - PatternComponent::Numbers(n) if *n == 0 => { - let mut len = 0; - while match_pos + len < line_bytes.len() - && line_bytes[match_pos + len].is_ascii_digit() { - len += 1; - } - len - } - PatternComponent::Letters(n) if *n == 0 => { - let mut len = 0; - while match_pos + len < line_bytes.len() - && line_bytes[match_pos + len].is_ascii_alphabetic() { - len += 1; - } - len - } - PatternComponent::Space(n) if *n == 0 => { - let mut len = 0; - while match_pos + len < line_bytes.len() - && line_bytes[match_pos + len] == b' ' { - len += 1; - } - len - } - PatternComponent::Tab(n) if *n == 0 => { - let mut len = 0; - while match_pos + len < line_bytes.len() - && line_bytes[match_pos + len] == b'\t' { - len += 1; + + let match_length = match component { + PatternComponent::Numbers(n) if *n == 0 => { + let mut len = 0; + while match_pos + len < line_bytes.len() + && line_bytes[match_pos + len].is_ascii_digit() { + len += 1; + } + len } - len - } - _ => component_length(component) - }; - - let match_end = match_pos + match_length; - last_match_end = match_end; - - line_pos = match_end; + PatternComponent::Letters(n) if *n == 0 => { + let mut len = 0; + while match_pos + len < line_bytes.len() + && line_bytes[match_pos + len].is_ascii_alphabetic() { + len += 1; + } + len + } + PatternComponent::Space(n) if *n == 0 => { + let mut len = 0; + while match_pos + len < line_bytes.len() + && line_bytes[match_pos + len] == b' ' { + len += 1; + } + len + } + PatternComponent::Tab(n) if *n == 0 => { + let mut len = 0; + while match_pos + len < line_bytes.len() + && line_bytes[match_pos + len] == b'\t' { + len += 1; + } + len + } + PatternComponent::Text(n) if *n == 0 => { + // Greedy match - extends to next component or EOL + if mask_idx + 1 < pattern.component_count { + if let Some(ref next_component) = pattern.components[mask_idx + 1] { + // Find where next component starts + if let Some(next_pos) = find_component_in_line_bytes(line_bytes, next_component, match_pos) { + if next_pos >= match_pos { + next_pos - match_pos + } else { + return false; + } + } else { + // Next component not found - pattern fails + return false; + } + } else { + line_bytes.len() - match_pos + } + } else { + // Last component - match to EOL + line_bytes.len() - match_pos + } + } + PatternComponent::Text(n) => { + // Match exactly n characters (returns byte length) + utf8_chars_byte_length(line_bytes, match_pos, *n) + } + _ => component_length(component) + }; + + let match_end = match_pos + match_length; + last_match_end = match_end; + + line_pos = match_end; + } + None => return false, } - None => return false, } } @@ -1359,4 +2161,155 @@ } +/// Match pattern and return the matched substring, or None if no match +fn line_matches_pattern_extract(line_bytes: &[u8], pattern: &CompiledPattern) -> Option { + if pattern.component_count == 0 { + return None; + } + + let mut line_pos = 0; + let mut first_match_start: Option = None; + let mut last_match_end = 0; + + for mask_idx in 0..pattern.component_count { + if let Some(ref component) = pattern.components[mask_idx] { + // Check if this component must be adjacent to previous + let must_be_adjacent = mask_idx > 0 && pattern.adjacent_to_next[mask_idx - 1]; + + if must_be_adjacent { + // Use O(component_length) check at exact position + match check_component_at_position(line_bytes, component, line_pos) { + Some(match_length) => { + // Handle greedy Text(0) - need to find next component + let actual_length = if matches!(component, PatternComponent::Text(0)) { + if mask_idx + 1 < pattern.component_count { + if let Some(ref next_component) = pattern.components[mask_idx + 1] { + if let Some(next_pos) = find_component_in_line_bytes(line_bytes, next_component, line_pos) { + if next_pos >= line_pos { + next_pos - line_pos + } else { + return None; + } + } else { + return None; + } + } else { + line_bytes.len() - line_pos + } + } else { + line_bytes.len() - line_pos + } + } else { + match_length + }; + + let match_end = line_pos + actual_length; + last_match_end = match_end; + line_pos = match_end; + } + None => return None, + } + } else { + // Use O(line_length) search from current position + match find_component_in_line_bytes(line_bytes, component, line_pos) { + Some(match_pos) => { + if mask_idx == 0 && pattern.start_anchor && match_pos != 0 { + return None; + } + + // Track start of first component match + if first_match_start.is_none() { + first_match_start = Some(match_pos); + } + + let match_length = match component { + PatternComponent::Numbers(n) if *n == 0 => { + let mut len = 0; + while match_pos + len < line_bytes.len() + && line_bytes[match_pos + len].is_ascii_digit() { + len += 1; + } + len + } + PatternComponent::Letters(n) if *n == 0 => { + let mut len = 0; + while match_pos + len < line_bytes.len() + && line_bytes[match_pos + len].is_ascii_alphabetic() { + len += 1; + } + len + } + PatternComponent::Space(n) if *n == 0 => { + let mut len = 0; + while match_pos + len < line_bytes.len() + && line_bytes[match_pos + len] == b' ' { + len += 1; + } + len + } + PatternComponent::Tab(n) if *n == 0 => { + let mut len = 0; + while match_pos + len < line_bytes.len() + && line_bytes[match_pos + len] == b'\t' { + len += 1; + } + len + } + PatternComponent::Text(n) if *n == 0 => { + // Greedy match - extends to next component or EOL + if mask_idx + 1 < pattern.component_count { + if let Some(ref next_component) = pattern.components[mask_idx + 1] { + // Find where next component starts + if let Some(next_pos) = find_component_in_line_bytes(line_bytes, next_component, match_pos) { + if next_pos >= match_pos { + next_pos - match_pos + } else { + return None; + } + } else { + // Next component not found - pattern fails + return None; + } + } else { + line_bytes.len() - match_pos + } + } else { + // Last component - match to EOL + line_bytes.len() - match_pos + } + } + PatternComponent::Text(n) => { + // Match exactly n characters (returns byte length) + utf8_chars_byte_length(line_bytes, match_pos, *n) + } + _ => component_length(component) + }; + + let match_end = match_pos + match_length; + last_match_end = match_end; + line_pos = match_end; + } + None => return None, + } + } + } + } + + if pattern.end_anchor { + if last_match_end != line_bytes.len() { + return None; + } + } + + // Extract the matched substring + let start = first_match_start.unwrap_or(0); + if start <= last_match_end && last_match_end <= line_bytes.len() { + std::str::from_utf8(&line_bytes[start..last_match_end]) + .ok() + .map(|s| s.to_string()) + } else { + None + } +} + fn line_matches_pattern_with_capture( line_bytes: &[u8], @@ -1395,6 +2348,13 @@ // Advance past the captured content AND the fence + // For Text, we need byte length, not char count let fence_length = match next_component { PatternComponent::Literal(_, len) => *len, + PatternComponent::Text(n) if *n == 0 => { + count_text_run_bytes(line_bytes, fence_pos) + } + PatternComponent::Text(n) => { + utf8_chars_byte_length(line_bytes, fence_pos, *n) + } _ => component_length(next_component) }; @@ -1426,4 +2386,42 @@ _ => { // Regular component matching + // Check if this component must be adjacent to previous + let must_be_adjacent = mask_idx > 0 && pattern.adjacent_to_next[mask_idx - 1]; + + if must_be_adjacent { + // Use O(component_length) check at exact position + match check_component_at_position(line_bytes, component, line_pos) { + Some(match_length) => { + // Handle greedy Text(0) - need to find next component + let actual_length = if matches!(component, PatternComponent::Text(0)) { + if mask_idx + 1 < pattern.component_count { + if let Some(ref next_component) = pattern.components[mask_idx + 1] { + if let Some(next_pos) = find_component_in_line_bytes(line_bytes, next_component, line_pos) { + if next_pos >= line_pos { + next_pos - line_pos + } else { + return (false, captures); + } + } else { + return (false, captures); + } + } else { + line_bytes.len() - line_pos + } + } else { + line_bytes.len() - line_pos + } + } else { + match_length + }; + + let match_end = line_pos + actual_length; + last_match_end = match_end; + line_pos = match_end; + } + None => return (false, captures), + } + } else { + // Use O(line_length) search from current position match find_component_in_line_bytes(line_bytes, component, line_pos) { Some(match_pos) => { @@ -1432,10 +2430,4 @@ } - if mask_idx > 0 && pattern.adjacent_to_next[mask_idx - 1] { - if match_pos != line_pos { - return (false, captures); - } - } - let match_length = match component { PatternComponent::Numbers(n) if *n == 0 => { @@ -1471,4 +2463,31 @@ len } + PatternComponent::Text(n) if *n == 0 => { + // Greedy match - extends to next component or EOL + if mask_idx + 1 < pattern.component_count { + if let Some(ref next_component) = pattern.components[mask_idx + 1] { + // Find where next component starts + if let Some(next_pos) = find_component_in_line_bytes(line_bytes, next_component, match_pos) { + if next_pos >= match_pos { + next_pos - match_pos + } else { + return (false, captures); + } + } else { + // Next component not found - pattern fails + return (false, captures); + } + } else { + line_bytes.len() - match_pos + } + } else { + // Last component - match to EOL + line_bytes.len() - match_pos + } + } + PatternComponent::Text(n) => { + // Match exactly n characters (returns byte length) + utf8_chars_byte_length(line_bytes, match_pos, *n) + } _ => component_length(component) }; @@ -1480,4 +2499,5 @@ None => return (false, captures), } + } } } @@ -1503,4 +2523,11 @@ .ok_or_else(|| NewbieError::new("No pattern compiled for find"))?; + // Handle &pipeline - read from stdin + if file_path == "&pipeline" { + let reader = get_reader("&pipeline")?; + execute_find_from_reader(reader, pattern, command)?; + return Ok(()); + } + // Check if the source is a variable reference if file_path.starts_with('&') { @@ -1577,5 +2604,5 @@ let has_variables = pattern_has_variables(pattern); - for line_result in reader.lines() { + for line in tolerant_lines(reader) { if check_interrupted() { clear_interrupted(); @@ -1585,8 +2612,4 @@ line_number = line_number.saturating_add(1); - let line = line_result.map_err(|e| - NewbieError::new(&format!("Error reading file: {}", e)) - )?; - // Use capture mode ONLY if command.capture_output is true let matched = if command.capture_output && has_variables { @@ -1607,4 +2630,7 @@ }; + // Apply ¬ negation if specified + let matched = if command.negate_condition { !matched } else { matched }; + if matched { match_buffer[buffer_pos] = Some((line_number, line)); @@ -1647,5 +2673,5 @@ std::thread::sleep(std::time::Duration::from_millis(100)); - if command.display_output && !command.raw_mode { + if command.display_output && !command.raw_mode && output_path != "&display" && output_path != "&pipeline" { println!("Results written to {}", output_path); } @@ -1677,5 +2703,5 @@ let has_variables = pattern_has_variables(pattern); - for line_result in reader.lines() { + for line in tolerant_lines(reader) { if check_interrupted() { clear_interrupted(); @@ -1693,8 +2719,4 @@ line_number = line_number.saturating_add(1); - let line = line_result.map_err(|e| - NewbieError::new(&format!("Error reading file: {}", e)) - )?; - // Use capture mode ONLY if command.capture_output is true let matched = if command.capture_output && has_variables { @@ -1714,4 +2736,7 @@ }; + // Apply ¬ negation if specified + let matched = if command.negate_condition { !matched } else { matched }; + if matched { match_count += 1; @@ -1734,5 +2759,5 @@ std::thread::sleep(std::time::Duration::from_millis(100)); - if command.display_output && !command.raw_mode { + if command.display_output && !command.raw_mode && output_path != "&display" && output_path != "&pipeline" { println!("Results written to {}", output_path); } @@ -1742,5 +2767,5 @@ let has_variables = pattern_has_variables(pattern); - for line_result in reader.lines() { + for line in tolerant_lines(reader) { if check_interrupted() { clear_interrupted(); @@ -1757,8 +2782,4 @@ line_number = line_number.saturating_add(1); - let line = line_result.map_err(|e| - NewbieError::new(&format!("Error reading file: {}", e)) - )?; - // Use capture mode ONLY if command.capture_output is true let matched = if command.capture_output && has_variables { @@ -1778,4 +2799,7 @@ }; + // Apply ¬ negation if specified + let matched = if command.negate_condition { !matched } else { matched }; + if matched { match_count += 1; @@ -1792,5 +2816,5 @@ let has_variables = pattern_has_variables(pattern); - for line_result in reader.lines() { + for line in tolerant_lines(reader) { if check_interrupted() { clear_interrupted(); @@ -1805,8 +2829,4 @@ } - let line = line_result.map_err(|e| - NewbieError::new(&format!("Error reading file: {}", e)) - )?; - // Use capture mode ONLY if command.capture_output is true let matched = if command.capture_output && has_variables { @@ -1826,4 +2846,7 @@ }; + // Apply ¬ negation if specified + let matched = if command.negate_condition { !matched } else { matched }; + if matched { match_count += 1; @@ -1989,33 +3012,93 @@ fn process_assignment_value(raw_value: &str) -> Result> { + // Check if value starts with &math - if so, evaluate as math expression + let trimmed = raw_value.trim(); + if trimmed.starts_with("&math ") || trimmed == "&math" { + let math_expr = if trimmed.len() > 6 { + &trimmed[6..] // Everything after "&math " + } else { + return Err(NewbieError::new("&math requires an expression")); + }; + return evaluate_math_expression(math_expr.trim()); + } + + // Check if value starts with &files &size - return file size as string + if trimmed.starts_with("&files &size ") { + let filename = &trimmed[13..].trim(); // Everything after "&files &size " + if filename.is_empty() { + return Err(NewbieError::new("&files &size requires a filename")); + } + let expanded_path = expand_tilde(filename); + let path = Path::new(&expanded_path); + if !path.exists() { + return Err(NewbieError::new(&format!("File not found: {}", expanded_path))); + } + let metadata = fs::metadata(&expanded_path) + .map_err(|e| NewbieError::new(&format!("Cannot get file size: {}", e)))?; + return Ok(metadata.len().to_string()); + } + + // Check if value starts with &match - match pattern against &newbie.line + if trimmed.starts_with("&match ") { + let pattern_str = &trimmed[7..].trim(); // Everything after "&match " + if pattern_str.is_empty() { + return Err(NewbieError::new("&match requires a pattern")); + } + + // Get current line from block context + let current_line = GLOBAL_VARS.with(|vars| { + vars.borrow().get("newbie.line").cloned() + }); + + let current_line = match current_line { + Some(line) => line, + None => return Err(NewbieError::new("&match can only be used inside a &block")), + }; + + // Parse the pattern + let mut pattern = CompiledPattern::new(); + parse_pattern_string(pattern_str, &mut pattern)?; + + // Match and extract + match line_matches_pattern_extract(current_line.as_bytes(), &pattern) { + Some(matched) => return Ok(matched), + None => return Ok(String::new()), // No match = empty string + } + } + let mut result = String::new(); - let bytes = raw_value.as_bytes(); - let mut pos = 0; + let mut chars = raw_value.char_indices().peekable(); - while pos < bytes.len() { + while let Some((pos, ch)) = chars.next() { // Check for &+ operator - if pos + 2 <= bytes.len() && &bytes[pos..pos+2] == b"&+" { - // Trim ONE trailing space from result (if present) - if result.ends_with(' ') { - result.pop(); + if ch == '&' { + let remaining = &raw_value[pos..]; + if remaining.starts_with("&+") { + // Trim ONE trailing space from result (if present) + if result.ends_with(' ') { + result.pop(); + } + + // Skip the &+ + chars.next(); // skip '+' + + // Skip ONE leading space after &+ (if present) + if let Some((_, next_ch)) = chars.peek() { + if *next_ch == ' ' { + chars.next(); + } + } + continue; } - pos += 2; - - // Skip ONE leading space after &+ (if present) - if pos < bytes.len() && bytes[pos] == b' ' { - pos += 1; - } - continue; - } - - // Check for variable reference starting with & - if bytes[pos] == b'&' { + // Check for variable reference starting with & let var_start = pos; let mut var_end = pos + 1; + // Find end of variable name (alphanumeric, dots, underscores) + let bytes = raw_value.as_bytes(); while var_end < bytes.len() { - let ch = bytes[var_end]; - if ch.is_ascii_alphanumeric() || ch == b'.' || ch == b'_' { + let b = bytes[var_end]; + if b.is_ascii_alphanumeric() || b == b'.' || b == b'_' { var_end += 1; } else { @@ -2025,11 +3108,17 @@ if var_end > var_start + 1 { - if let Ok(var_ref) = std::str::from_utf8(&bytes[var_start..var_end]) { - if let Some((namespace, name)) = parse_variable_reference(var_ref) { - if let Some(value) = get_variable(namespace, &name) { - result.push_str(&value); - pos = var_end; - continue; + let var_ref = &raw_value[var_start..var_end]; + if let Some((namespace, name)) = parse_variable_reference(var_ref) { + if let Some(value) = get_variable(namespace, &name) { + result.push_str(&value); + // Skip chars we've consumed + while let Some((i, _)) = chars.peek() { + if *i < var_end { + chars.next(); + } else { + break; + } } + continue; } } @@ -2038,6 +3127,5 @@ // Regular character - keep it - result.push(bytes[pos] as char); - pos += 1; + result.push(ch); } @@ -2052,4 +3140,16 @@ } + // ERROR CASE: &math at start of line is not valid + // Valid forms are: &v.x &= &math expr OR &if &math expr + if trimmed.starts_with("&math ") || trimmed == "&math" { + return Err(NewbieError::new( + "&math cannot start a line. Valid forms are:\n &v.x &= &math 2+2\n &if &math &v.x > 3")); + } + + // SPECIAL CASE: Increment/decrement operators (&v.varname++ or &v.varname--) + if let Some(()) = try_handle_increment_decrement(trimmed)? { + return Ok(()); + } + // SPECIAL CASE: Variable assignment with &= (not a registered keyword) if let Some((namespace, var_name, raw_value)) = try_parse_variable_assignment(trimmed) { @@ -2071,11 +3171,19 @@ } + // Track keywords to skip (consumed by previous keyword) + let mut skip_next = false; + // Process each keyword with its content for i in 0..kw_count { + if skip_next { + skip_next = false; + continue; + } + let (keyword, handler, start_pos) = keyword_positions[i]; // Content goes from end of keyword to next keyword (or EOL) let content_start = start_pos + keyword.len(); - let content_end = if i + 1 < kw_count { + let mut content_end = if i + 1 < kw_count { keyword_positions[i + 1].2 // Start of next keyword } else { @@ -2083,4 +3191,36 @@ }; + // Special case: &if with variable-first syntax needs to capture &empty as part of args + // Check if this is &if, content looks like a variable, and next keyword is &empty + if keyword == "&if" && i + 1 < kw_count { + let next_keyword = keyword_positions[i + 1].0; + if next_keyword == "&empty" { + let content_preview = if content_start < content_end { + trimmed[content_start..content_end].trim() + } else { + "" + }; + // Check if content starts with ¬ (optional) followed by a variable reference + let check_content = if content_preview.starts_with("¬ ") { + content_preview.strip_prefix("¬ ").unwrap_or("").trim() + } else { + content_preview + }; + // If content is just a variable reference, extend to include &empty + if check_content.starts_with("&v.") || check_content.starts_with("&global.") || + check_content.starts_with("&config.") || check_content.starts_with("&system.") || + check_content.starts_with("&process.") || check_content.starts_with("&newbie.") { + // Extend content_end to include &empty + let empty_end = if i + 2 < kw_count { + keyword_positions[i + 2].2 + } else { + trimmed.len() + }; + content_end = empty_end; + skip_next = true; // Skip the &empty keyword + } + } + } + let content = if content_start < content_end { trimmed[content_start..content_end].trim() @@ -2120,4 +3260,33 @@ } +// Helper to detect variable-first &if syntax: &if &v.name &empty +fn is_empty_after_variable(line: &str) -> bool { + if let Some(empty_pos) = line.find(" &empty") { + // Look at what comes before " &empty" + let before = &line[..empty_pos]; + // Find the last whitespace-separated word before &empty + if let Some(last_space) = before.rfind(' ') { + let word = &before[last_space + 1..]; + // Check if it looks like a variable reference + word.starts_with("&v.") || + word.starts_with("&global.") || + word.starts_with("&config.") || + word.starts_with("&system.") || + word.starts_with("&process.") || + word.starts_with("&newbie.") + } else { + // No space before - check the whole before string + before.starts_with("&v.") || + before.starts_with("&global.") || + before.starts_with("&config.") || + before.starts_with("&system.") || + before.starts_with("&process.") || + before.starts_with("&newbie.") + } + } else { + false + } +} + fn find_all_keywords_fixed(line: &str) -> Result<([(&'static str, CommandHandler, usize); 64], usize), Box> { const MAX_KEYWORDS_PER_LINE: usize = 64; @@ -2126,5 +3295,17 @@ let mut count = 0; + // Check if &empty is being used as an &if test (not a command) + // Handles both keyword-first: &if &empty &v.name + // And variable-first: &if &v.name &empty (or &if ¬ &v.name &empty) + let empty_is_if_test = line.contains("&if &empty") || + line.contains("&if ¬ &empty") || + is_empty_after_variable(line); + for entry in KEYWORDS.iter() { + // Special case: &empty is skipped when used as &if test + if entry.name == "&empty" && empty_is_if_test { + continue; + } + let keyword = entry.name; let keyword_len = keyword.len(); @@ -2194,5 +3375,5 @@ fn needs_tokenization(keyword: &str) -> bool { matches!(keyword, - "&first" | "&last" | "&set" | "&get" | "&vars" | "&global" | "&empty" | "&run" + "&first" | "&last" | "&vars" | "&global" | "&empty" | "&run" | "&if" ) } @@ -2203,154 +3384,4 @@ } -#[allow(dead_code)] -fn detect_set_context( - tokens: &[Option<&str>; MAX_TOKENS_PER_LINE], - token_count: usize, - var_index: usize -) -> bool { - if var_index + 2 < token_count { - if let (Some(_var_token), Some(equals_token), Some(_value_token)) = - (tokens[var_index], tokens[var_index + 1], tokens[var_index + 2]) { - if equals_token == "=" { - return true; - } - } - } - - if var_index + 1 < token_count { - if let Some(next_token) = tokens[var_index + 1] { - if !next_token.starts_with('&') && next_token != "=" { - return true; - } - } - } - - if var_index > 0 { - if let Some(prev_token) = tokens[var_index - 1] { - match prev_token { - "&show" | "&find" | "©" | "&move" | "&run" => { - return false; - } - "=" => { - return false; - } - _ => {} - } - } - } - - if var_index == 0 { - if var_index + 1 < token_count { - if let Some(next_token) = tokens[var_index + 1] { - if next_token == "=" || (!next_token.starts_with('&') && next_token != "=") { - return true; - } - } - } - } - - false -} - -#[allow(dead_code)] -fn parse_tokens_with_set_prefix( - tokens: &[Option<&str>; MAX_TOKENS_PER_LINE], - token_count: usize, - command: Command -) -> Result<(), Box> { - let mut new_tokens: [Option<&str>; MAX_TOKENS_PER_LINE] = [None; MAX_TOKENS_PER_LINE]; - new_tokens[0] = Some("&set"); - - let copy_count = std::cmp::min(token_count, MAX_TOKENS_PER_LINE - 1); - for i in 0..copy_count { - new_tokens[i + 1] = tokens[i]; - } - - parse_tokens_fixed_size(&new_tokens, copy_count + 1, command) -} - -#[allow(dead_code)] -fn parse_tokens_with_get_prefix( - tokens: &[Option<&str>; MAX_TOKENS_PER_LINE], - token_count: usize, - command: Command -) -> Result<(), Box> { - let mut new_tokens: [Option<&str>; MAX_TOKENS_PER_LINE] = [None; MAX_TOKENS_PER_LINE]; - new_tokens[0] = Some("&get"); - - let copy_count = std::cmp::min(token_count, MAX_TOKENS_PER_LINE - 1); - for i in 0..copy_count { - new_tokens[i + 1] = tokens[i]; - } - - parse_tokens_fixed_size(&new_tokens, copy_count + 1, command) -} - -#[allow(dead_code)] -fn parse_tokens_fixed_size( - tokens: &[Option<&str>; MAX_TOKENS_PER_LINE], - token_count: usize, - mut command: Command -) -> Result<(), Box> { - let mut current_keyword: Option<&str> = None; - let mut current_args: [Option<&str>; MAX_ARGS_PER_KEYWORD] = [None; MAX_ARGS_PER_KEYWORD]; - let mut arg_count = 0; - - for i in 0..token_count { - if let Some(token) = tokens[i] { - if token.starts_with('&') && !token.contains('.') && is_registered_keyword(token) { - if let Some(keyword) = current_keyword.take() { - let mut args_slice: [&str; MAX_ARGS_PER_KEYWORD] = [""; MAX_ARGS_PER_KEYWORD]; - let mut valid_count = 0; - - for j in 0..arg_count { - if let Some(arg) = current_args[j] { - args_slice[valid_count] = arg; - valid_count += 1; - } - } - - if let Some(handler) = find_handler(keyword) { - handler(&args_slice[..valid_count], &mut command)?; - } else { - return Err(NewbieError::new(&format!("Unknown keyword: {}", keyword))); - } - - current_args = [None; MAX_ARGS_PER_KEYWORD]; - arg_count = 0; - } - - current_keyword = Some(token); - } else { - if arg_count < MAX_ARGS_PER_KEYWORD { - current_args[arg_count] = Some(token); - arg_count += 1; - } else { - return Err(NewbieError::new("Too many arguments for keyword")); - } - } - } - } - - if let Some(keyword) = current_keyword { - let mut args_slice: [&str; MAX_ARGS_PER_KEYWORD] = [""; MAX_ARGS_PER_KEYWORD]; - let mut valid_count = 0; - - for j in 0..arg_count { - if let Some(arg) = current_args[j] { - args_slice[valid_count] = arg; - valid_count += 1; - } - } - - if let Some(handler) = find_handler(keyword) { - handler(&args_slice[..valid_count], &mut command)?; - } else { - return Err(NewbieError::new(&format!("Unknown keyword: {}", keyword))); - } - } - - execute_command(&command) -} fn expand_tilde(path: &str) -> String { @@ -2456,5 +3487,5 @@ #[allow(dead_code)] -fn page_lines_from_reader(reader: BufReader, command: &Command) -> Result<(), Box> { +fn page_lines_from_reader(mut reader: BufReader, command: &Command) -> Result<(), Box> { use std::io::{self, Write}; @@ -2462,19 +3493,26 @@ let page_lines = if rows > 2 { rows - 2 } else { rows }; - let mut lines_iter = reader.lines(); + let mut buffer = [0u8; MAX_LINE_SIZE]; let mut line_no = 1usize; loop { for _ in 0..page_lines { - match lines_iter.next() { - Some(Ok(line)) => { - if command.numbered || command.original_numbers { - println!("{:6}: {}", line_no, line); - } else { - println!("{}", line); + match read_line_into_buffer(&mut reader, &mut buffer) { + Some(len) => { + match std::str::from_utf8(&buffer[..len]) { + Ok(line) => { + if command.numbered || command.original_numbers { + println!("{:6}: {}", line_no, line); + } else { + println!("{}", line); + } + line_no += 1; + } + Err(_) => { + log_bad_line_bytes(&buffer[..len]); + continue; + } } - line_no += 1; } - Some(Err(e)) => return Err(NewbieError::new(&format!("Error reading file: {}", e))), None => return Ok(()), } @@ -2503,20 +3541,46 @@ // Evaluate a conditional expression for &if fn evaluate_condition(condition: &str, negate: bool) -> bool { - let result = if condition.starts_with('&') { - // It's a variable reference - if let Some((namespace, name)) = parse_variable_reference(condition) { - // Variable is "true" if it exists and is non-empty + let result = if let Some(path) = condition.strip_prefix("exists:") { + // &if &exists filename - check if file exists + let expanded_path = expand_tilde(path); + Path::new(&expanded_path).exists() + } else if let Some(var_ref) = condition.strip_prefix("empty:") { + // &if &empty &v.varname - check if variable is empty or undefined + if let Some((namespace, name)) = parse_variable_reference(var_ref) { + if let Some(value) = get_variable(namespace, &name) { + value.is_empty() + } else { + true // undefined counts as empty + } + } else { + true // invalid reference counts as empty + } + } else if let Some(var_ref) = condition.strip_prefix("filled:") { + // &if &v.varname &filled - check if variable has a value + if let Some((namespace, name)) = parse_variable_reference(var_ref) { if let Some(value) = get_variable(namespace, &name) { !value.is_empty() } else { - false + false // undefined is not filled } } else { - false + false // invalid reference is not filled + } + } else if let Some(math_expr) = condition.strip_prefix("math:") { + // &if &math expression - evaluate math, true if result != 0 + match evaluate_math_expression(math_expr) { + Ok(result_str) => { + // Parse result as number, true if non-zero + if let Ok(num) = result_str.parse::() { + num != 0.0 + } else { + false + } + } + Err(_) => false } } else { - // It's a file path - check if file exists - let expanded_path = expand_tilde(condition); - Path::new(&expanded_path).exists() + // Legacy format or unknown - shouldn't happen with new parser + false }; @@ -2546,5 +3610,4 @@ Some("license") => { - println!("Newbie 1.0"); println!("©2025 Mark Allen Battey"); println!(); @@ -2659,4 +3722,10 @@ execute_delete_command(file_path, command)?; }, + Some("create") => { + let path = command.source.as_ref() + .ok_or_else(|| NewbieError::new("&create requires a path"))?; + + execute_create_command(path, command)?; + }, Some("set_variable") => { if command.display_output && !command.raw_mode { @@ -2768,14 +3837,19 @@ let expanded_content = expand_variables_in_string(content_str)?; - // Open file in append mode (or create if doesn't exist) - let expanded_path = expand_tilde(filename); - let file = OpenOptions::new() - .create(true) - .append(true) - .open(&expanded_path)?; - - let mut writer = BufWriter::new(file); - writeln!(writer, "{}", expanded_content)?; - writer.flush()?; + // Check for &display - write to stdout instead of file + if filename == "&display" { + println!("{}", expanded_content); + } else { + // Open file in append mode (or create if doesn't exist) + let expanded_path = expand_tilde(filename); + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&expanded_path)?; + + let mut writer = BufWriter::new(file); + writeln!(writer, "{}", expanded_content)?; + writer.flush()?; + } }, @@ -2799,4 +3873,13 @@ execute_sort_command(input_path, output_path, command)?; }, + + Some("unicode") => { + let input_path = command.source.as_ref() + .ok_or_else(|| NewbieError::new("&unicode requires input file"))?; + let output_path = command.output_file.as_ref() + .ok_or_else(|| NewbieError::new("&unicode requires &into output file"))?; + + execute_unicode_command(input_path, output_path, command)?; + }, Some(action) => { @@ -2814,33 +3897,37 @@ fn expand_variables_in_string(input: &str) -> Result> { let mut result = String::new(); - let mut pos = 0; - let bytes = input.as_bytes(); + let mut chars = input.char_indices().peekable(); - while pos < bytes.len() { + while let Some((pos, ch)) = chars.next() { // Check for &+ operator - if pos + 2 <= bytes.len() && &bytes[pos..pos+2] == b"&+" { - // Trim ONE trailing space from result (if present) - if result.ends_with(' ') { - result.pop(); + if ch == '&' { + let remaining = &input[pos..]; + if remaining.starts_with("&+") { + // Trim ONE trailing space from result (if present) + if result.ends_with(' ') { + result.pop(); + } + + // Skip the &+ + chars.next(); // skip '+' + + // Skip ONE leading space after &+ (if present) + if let Some((_, next_ch)) = chars.peek() { + if *next_ch == ' ' { + chars.next(); + } + } + continue; } - pos += 2; - - // Skip ONE leading space after &+ (if present) - if pos < bytes.len() && bytes[pos] == b' ' { - pos += 1; - } - continue; - } - - // Check for variable reference starting with & - if bytes[pos] == b'&' { + // Check for variable reference starting with & let var_start = pos; let mut var_end = pos + 1; // Find the end of the variable reference (alphanumeric, . or _ only) + let bytes = input.as_bytes(); while var_end < bytes.len() { - let ch = bytes[var_end]; - if ch.is_ascii_alphanumeric() || ch == b'.' || ch == b'_' { + let b = bytes[var_end]; + if b.is_ascii_alphanumeric() || b == b'.' || b == b'_' { var_end += 1; } else { @@ -2850,11 +3937,17 @@ if var_end > var_start + 1 { - if let Ok(var_ref) = std::str::from_utf8(&bytes[var_start..var_end]) { - if let Some((namespace, name)) = parse_variable_reference(var_ref) { - if let Some(value) = get_variable(namespace, &name) { - result.push_str(&value); - pos = var_end; - continue; + let var_ref = &input[var_start..var_end]; + if let Some((namespace, name)) = parse_variable_reference(var_ref) { + if let Some(value) = get_variable(namespace, &name) { + result.push_str(&value); + // Skip chars we've consumed + while let Some((i, _)) = chars.peek() { + if *i < var_end { + chars.next(); + } else { + break; + } } + continue; } } @@ -2862,6 +3955,5 @@ } - result.push(bytes[pos] as char); - pos += 1; + result.push(ch); } @@ -2881,5 +3973,5 @@ // For Stage 1: Just iterate through lines and set &newbie.line // We'll print it for testing - for line_result in reader.lines() { + for line in tolerant_lines(reader) { if check_interrupted() { clear_interrupted(); @@ -2887,8 +3979,4 @@ } - let line = line_result.map_err(|e| - NewbieError::new(&format!("Error reading file: {}", e)) - )?; - // Set &newbie.line (we'll use the GLOBAL_VARS for now as a simple test) GLOBAL_VARS.with(|vars| { @@ -2979,23 +4067,32 @@ } - let reader = get_reader(&expanded_path)?; + let mut reader = get_reader(&expanded_path)?; let mut patterns = Vec::new(); let mut replacements = Vec::new(); - let mut lines_iter = reader.lines(); + let mut buffer = [0u8; MAX_LINE_SIZE]; loop { // Read pattern (odd line) - let pattern = match lines_iter.next() { - Some(Ok(line)) => line, - Some(Err(e)) => return Err(NewbieError::new(&format!("Error reading dictionary: {}", e))), + let pattern = match read_line_into_buffer(&mut *reader, &mut buffer) { + Some(len) => { + match std::str::from_utf8(&buffer[..len]) { + Ok(s) => s.to_string(), + Err(_) => { log_bad_line_bytes(&buffer[..len]); continue; } + } + } None => break, }; // Read replacement (even line) - let replacement = match lines_iter.next() { - Some(Ok(line)) => line, - Some(Err(e)) => return Err(NewbieError::new(&format!("Error reading dictionary: {}", e))), + let replacement = match read_line_into_buffer(&mut *reader, &mut buffer) { + Some(len) => { + match std::str::from_utf8(&buffer[..len]) { + Ok(s) => s.to_string(), + Err(_) => { log_bad_line_bytes(&buffer[..len]); continue; } + } + } None => { - return Err(NewbieError::new("Dictionary file has odd number of lines")); + // Odd number of valid lines - pattern without replacement + break; } }; @@ -3050,5 +4147,5 @@ // Process line by line - for line_result in reader.lines() { + for line in tolerant_lines(reader) { if check_interrupted() { clear_interrupted(); @@ -3059,8 +4156,4 @@ } - let line = line_result.map_err(|e| - NewbieError::new(&format!("Error reading input: {}", e)) - )?; - // Apply all replacements using Aho-Corasick let modified = apply_replacements_ac(&line, &ac, &replacements); @@ -3103,5 +4196,4 @@ let _inhibitor = create_sleep_inhibitor(); - // Read all lines into memory let expanded_input = expand_tilde(input_path); if !Path::new(&expanded_input).exists() { @@ -3109,17 +4201,78 @@ } - let reader = get_reader(&expanded_input)?; - let mut lines: Vec = reader.lines().collect::>()?; + let expanded_output = expand_tilde(output_path); - // Sort the lines - lines.sort(); + // Use system sort -u for efficiency on large files + let mut child = StdCommand::new("sort") + .arg("-u") + .arg(&expanded_input) + .arg("-o") + .arg(&expanded_output) + .spawn() + .map_err(|e| NewbieError::new(&format!("Failed to run sort: {}", e)))?; - // Write sorted lines + // Poll for completion, checking for Ctrl-C + loop { + if check_interrupted() { + clear_interrupted(); + let _ = child.kill(); + return Ok(()); + } + + match child.try_wait() { + Ok(Some(status)) => { + if !status.success() { + return Err(NewbieError::new("sort command failed")); + } + break; + } + Ok(None) => { + // Still running, sleep briefly + std::thread::sleep(std::time::Duration::from_millis(50)); + } + Err(e) => { + return Err(NewbieError::new(&format!("Error waiting for sort: {}", e))); + } + } + } + + if command.display_output && !command.raw_mode { + println!("Sorted {} to {}", input_path, output_path); + } + + Ok(()) +} + +fn execute_unicode_command( + input_path: &str, + output_path: &str, + command: &Command +) -> Result<(), Box> { + // Prevent system sleep during long-running operation + let _inhibitor = create_sleep_inhibitor(); + + let expanded_input = expand_tilde(input_path); + if !Path::new(&expanded_input).exists() { + return Err(NewbieError::new(&format!("Input file not found: {}", expanded_input))); + } + + let reader = get_reader(&expanded_input)?; let expanded_output = expand_tilde(output_path); let tx = spawn_writer_thread(expanded_output.clone())?; - for line in lines { - let line_buffer = LineBuffer::from_str(&line); - if tx.send(line_buffer).is_err() { + // Process line by line + for line in tolerant_lines(reader) { + if check_interrupted() { + clear_interrupted(); + drop(tx); + return Ok(()); + } + + // Convert unicode escapes in this line + let converted = convert_unicode_escapes(&line); + + // Write output + let output_buffer = LineBuffer::from_str(&converted); + if tx.send(output_buffer).is_err() { return Err(NewbieError::new("Writer thread failed")); } @@ -3130,5 +4283,5 @@ if command.display_output && !command.raw_mode { - println!("Sorted {} to {}", input_path, output_path); + println!("Converted unicode escapes: {} to {}", input_path, output_path); } @@ -3136,4 +4289,79 @@ } +/// Convert \uXXXX and \UXXXXXXXX escape sequences to actual Unicode characters. +/// Invalid sequences pass through unchanged. +fn convert_unicode_escapes(input: &str) -> String { + let mut result = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '\\' { + // Peek at next character + match chars.peek() { + Some(&'u') => { + chars.next(); // consume 'u' + // Try to read 4 hex digits + let mut hex = String::with_capacity(4); + for _ in 0..4 { + match chars.peek() { + Some(&c) if c.is_ascii_hexdigit() => { + hex.push(c); + chars.next(); + } + _ => break, + } + } + if hex.len() == 4 { + if let Ok(codepoint) = u32::from_str_radix(&hex, 16) { + if let Some(unicode_char) = char::from_u32(codepoint) { + result.push(unicode_char); + continue; + } + } + } + // Failed to parse - emit the original \u and hex chars + result.push('\\'); + result.push('u'); + result.push_str(&hex); + } + Some(&'U') => { + chars.next(); // consume 'U' + // Try to read 8 hex digits + let mut hex = String::with_capacity(8); + for _ in 0..8 { + match chars.peek() { + Some(&c) if c.is_ascii_hexdigit() => { + hex.push(c); + chars.next(); + } + _ => break, + } + } + if hex.len() == 8 { + if let Ok(codepoint) = u32::from_str_radix(&hex, 16) { + if let Some(unicode_char) = char::from_u32(codepoint) { + result.push(unicode_char); + continue; + } + } + } + // Failed to parse - emit the original \U and hex chars + result.push('\\'); + result.push('U'); + result.push_str(&hex); + } + _ => { + // Not a unicode escape, just emit the backslash + result.push(ch); + } + } + } else { + result.push(ch); + } + } + + result +} + fn execute_show_command(file_path: &str, command: &Command) -> Result<(), Box> { if let Some(ref output_path) = command.output_file { @@ -3165,11 +4393,7 @@ if let Some(first_n) = command.first_n { let mut count = 0; - for line_result in reader.lines() { + for line in tolerant_lines(reader) { if count >= first_n { break; } - let line = line_result.map_err(|e| - NewbieError::new(&format!("Error reading file: {}", e)) - )?; - let output_str = if command.numbered || command.original_numbers { format!("{:6}: {}", count + 1, line) @@ -3201,9 +4425,5 @@ let mut buffer_pos = 0; - for line_result in reader.lines() { - let line = line_result.map_err(|e| - NewbieError::new(&format!("Error reading file: {}", e)) - )?; - + for line in tolerant_lines(reader) { line_buffer_array[buffer_pos] = Some(line); buffer_pos = (buffer_pos + 1) % MAX_LAST_LINES; @@ -3240,11 +4460,7 @@ } else { let mut line_number = 0; - for line_result in reader.lines() { + for line in tolerant_lines(reader) { line_number += 1; - let line = line_result.map_err(|e| - NewbieError::new(&format!("Error reading file: {}", e)) - )?; - let output_str = if command.numbered || command.original_numbers { format!("{:6}: {}", line_number, line) @@ -3550,6 +4766,6 @@ } - // Column width = name + spacing + size - let col_width = max_name_width + 3 + max_size_width; + // Column width = name + spacing + size + inter-column spacing + let col_width = max_name_width + 3 + max_size_width + 4; let num_cols = std::cmp::max(1, term_cols / col_width); let num_rows = (entry_count + num_cols - 1) / num_cols; @@ -3638,10 +4854,17 @@ // List current directory command.source = Some(".".to_string()); + } else if args[0] == "&size" { + // &files &size filename - return raw byte count + command.files_size_only = true; + if args.len() < 2 { + return Err(NewbieError::new("&files &size requires a filename")); + } + command.source = Some(args[1].to_string()); } else { - // List specified directory + // List specified directory or file command.source = Some(args[0].to_string()); } - Ok(ExecutionResult::Stop) + Ok(ExecutionResult::Continue) } @@ -3719,5 +4942,10 @@ // Extract input file (between &sort and &into) - let input_file = line[sort_pos + 6..into_pos].trim(); + let input_start = sort_pos + 6; + let input_file = if input_start <= into_pos { + line[input_start..into_pos].trim() + } else { + "" + }; if input_file.is_empty() { @@ -3735,4 +4963,38 @@ } +fn handle_unicode(_args: &[&str], command: &mut Command) -> Result> { + command.action = Some("unicode".to_string()); + + let line = &command.original_line; + + // Find &unicode and &into positions + let unicode_pos = line.find("&unicode ") + .ok_or_else(|| NewbieError::new("Could not locate &unicode keyword"))?; + + let into_pos = line.find(" &into ") + .ok_or_else(|| NewbieError::new("&unicode requires &into delimiter"))?; + + // Extract input file (between &unicode and &into) + let input_start = unicode_pos + 9; // length of "&unicode " + let input_file = if input_start <= into_pos { + line[input_start..into_pos].trim() + } else { + "" + }; + + if input_file.is_empty() { + return Err(NewbieError::new("&unicode requires input file")); + } + + // Extract output file (after &into) using shared helper + let output_file = extract_filepath_to_eol(line, into_pos + 7) + .map_err(|_| NewbieError::new("&unicode requires output file after &into"))?; + + command.source = Some(input_file.to_string()); + command.output_file = Some(output_file); + + Ok(ExecutionResult::Stop) +} + fn handle_write(_args: &[&str], command: &mut Command) -> Result> { let line = &command.original_line; @@ -3746,5 +5008,10 @@ // Content to write is between &write and &to - let content_str = line[write_pos + 7..to_pos].trim(); + let content_start = write_pos + 7; + let content_str = if content_start <= to_pos { + line[content_start..to_pos].trim() + } else { + "" + }; // Extract filename after &to using shared helper @@ -3786,15 +5053,77 @@ fn handle_if(args: &[&str], command: &mut Command) -> Result> { if args.is_empty() { - return Err(NewbieError::new("&if requires a condition (file path or variable)")); + return Err(NewbieError::new("&if requires a test type: &exists, &empty, &filled, &math, or a variable")); + } + + let mut idx = 0; + + // Check for ¬ modifier + if args[idx] == "¬" { + command.negate_condition = true; + idx += 1; + if idx >= args.len() { + return Err(NewbieError::new("&if ¬ requires a test type or variable")); + } + } + + // Parse test type - check for keywords first, then variable-first syntax + match args[idx] { + "&exists" => { + idx += 1; + if idx >= args.len() { + return Err(NewbieError::new("&if &exists requires a filename")); + } + command.if_condition = Some(format!("exists:{}", args[idx])); + } + "&empty" => { + idx += 1; + if idx >= args.len() { + return Err(NewbieError::new("&if &empty requires a variable")); + } + command.if_condition = Some(format!("empty:{}", args[idx])); + } + "&filled" => { + idx += 1; + if idx >= args.len() { + return Err(NewbieError::new("&if &filled requires a variable")); + } + command.if_condition = Some(format!("filled:{}", args[idx])); + } + "&math" => { + // Collect remaining args as the math expression + idx += 1; + if idx >= args.len() { + return Err(NewbieError::new("&if &math requires an expression")); + } + let math_expr: String = args[idx..].join(" "); + command.if_condition = Some(format!("math:{}", math_expr)); + } + _ => { + // Check for variable-first syntax: &if &v.varname &filled/&empty + if parse_variable_reference(args[idx]).is_some() { + let var_ref = args[idx]; + idx += 1; + if idx >= args.len() { + return Err(NewbieError::new("&if requires &filled or &empty")); + } + match args[idx] { + "&filled" => { + command.if_condition = Some(format!("filled:{}", var_ref)); + } + "&empty" => { + command.if_condition = Some(format!("empty:{}", var_ref)); + } + _ => { + return Err(NewbieError::new(&format!( + "Unknown test after variable: {}. Use &filled or &empty", args[idx]))); + } + } + } else { + return Err(NewbieError::new(&format!( + "Unknown &if test type: {}. Use &exists, &empty, &filled, &math, or a variable", args[idx]))); + } + } } - // Store the condition to be evaluated at execution time - command.if_condition = Some(args[0].to_string()); - Ok(ExecutionResult::Continue) -} - -fn handle_not(_args: &[&str], command: &mut Command) -> Result> { - // ¬ sets a flag to negate the next condition - command.negate_condition = true; Ok(ExecutionResult::Continue) } @@ -3807,4 +5136,20 @@ fn spawn_writer_thread(path: String) -> Result, Box> { let (tx, rx) = mpsc::sync_channel::(1024); + + // Handle &pipeline and &display - write to stdout + if path == "&pipeline" || path == "&display" { + thread::spawn(move || { + use std::io::Write; + let stdout = std::io::stdout(); + let mut writer = stdout.lock(); + for line_buffer in rx.iter() { + if writer.write_all(line_buffer.as_bytes()).is_err() { break; } + if writer.write_all(b"\n").is_err() { break; } + } + let _ = writer.flush(); + }); + return Ok(tx); + } + let compression = select_compression_from_extension(&path); @@ -3880,13 +5225,9 @@ let mut lines_printed = 0; - for line_result in reader.lines() { + for line in tolerant_lines(reader) { if lines_printed >= first_n { break; } - let line = line_result.map_err(|e| - NewbieError::new(&format!("Error reading file: {}", e)) - )?; - let formatted_line = format_output_line(&line, command.wrap_lines); @@ -3921,9 +5262,5 @@ let mut buffer_pos = 0; - for line_result in reader.lines() { - let line = line_result.map_err(|e| - NewbieError::new(&format!("Error reading file: {}", e)) - )?; - + for line in tolerant_lines(reader) { line_buffer[buffer_pos] = Some(line); buffer_pos = (buffer_pos + 1) % MAX_LAST_LINES; @@ -3965,5 +5302,5 @@ let mut line_number = 1; - for line_result in reader.lines() { + for line in tolerant_lines(reader) { // Check for Ctrl-C if check_interrupted() { @@ -3972,8 +5309,4 @@ } - let line = line_result.map_err(|e| - NewbieError::new(&format!("Error reading file: {}", e)) - )?; - let formatted_line = format_output_line(&line, command.wrap_lines); @@ -3992,5 +5325,6 @@ let page_lines = if rows > 2 { rows - 2 } else { rows }; - let mut lines_iter = reader.lines(); + let mut reader = reader; + let mut buffer = [0u8; MAX_LINE_SIZE]; let mut line_number = 1; let mut lines_on_page = 0; @@ -4004,37 +5338,41 @@ } - match lines_iter.next() { - Some(Ok(line)) => { - let formatted_line = format_output_line(&line, command.wrap_lines); - if command.numbered || command.original_numbers { - println!("{:6}: {}", line_number, formatted_line); - } else { - println!("{}", formatted_line); - } - line_number += 1; - lines_on_page += 1; - - if !scripting_mode && lines_on_page >= page_lines { - print!("--More-- (space for next, q or ESC to quit)"); - io::stdout().flush().ok(); - - match read_single_key()? { - Key::Space => { - println!(); - lines_on_page = 0; - continue; - } - Key::Q | Key::Esc => { - println!(); - return Ok(()); + match read_line_into_buffer(&mut *reader, &mut buffer) { + Some(len) => { + match std::str::from_utf8(&buffer[..len]) { + Ok(line) => { + let formatted_line = format_output_line(line, command.wrap_lines); + if command.numbered || command.original_numbers { + println!("{:6}: {}", line_number, formatted_line); + } else { + println!("{}", formatted_line); } - _ => { - println!(); - return Ok(()); + line_number += 1; + lines_on_page += 1; + + if !scripting_mode && lines_on_page >= page_lines { + print!("--More-- (space for next, q or ESC to quit)"); + io::stdout().flush().ok(); + + match read_single_key()? { + Key::Space => { + println!(); + lines_on_page = 0; + continue; + } + Key::Q | Key::Esc => { + println!(); + return Ok(()); + } + _ => { + println!(); + return Ok(()); + } + } } } + Err(_) => { log_bad_line_bytes(&buffer[..len]); continue; } } } - Some(Err(e)) => return Err(NewbieError::new(&format!("Error reading file: {}", e))), None => return Ok(()), } @@ -4260,4 +5598,5 @@ increment_recursion()?; let _recursion_guard = RecursionGuard; + let _inhibitor = create_sleep_inhibitor(); if !Path::new(script_path).exists() { @@ -4269,5 +5608,5 @@ )?; - let reader = BufReader::new(file); + let reader: Box = Box::new(BufReader::new(file)); let mut line_number = 0; @@ -4283,14 +5622,11 @@ let mut pattern_count = 0; let mut block_input_file: Option = None; + let mut block_is_files_mode = false; let mut block_lines: [Option; MAX_BLOCK_LINES] = [const { None }; MAX_BLOCK_LINES]; let mut block_line_count = 0; - for line_result in reader.lines() { + for line in tolerant_lines(reader) { line_number += 1; - let line = line_result.map_err(|e| - NewbieError::new(&format!("Error reading line {} in {}: {}", line_number, script_path, e)) - )?; - let trimmed_line = line.trim(); @@ -4306,5 +5642,19 @@ let after_block = &trimmed_line[block_pos + 7..].trim(); - // Extract just the filename (first token after &block) + // Check for &files mode: &block &files dirname + if after_block.starts_with("&files ") { + let dir_path = after_block.strip_prefix("&files ").unwrap().trim(); + if dir_path.is_empty() { + return Err(NewbieError::new(&format!( + "Line {}: &block &files requires directory path", line_number + ))); + } + block_input_file = Some(dir_path.to_string()); + block_is_files_mode = true; + state = ParseState::InBlock; + continue; + } + + // Normal mode: &block filename let input_file = after_block .split_whitespace() @@ -4315,4 +5665,5 @@ block_input_file = Some(input_file.to_string()); + block_is_files_mode = false; state = ParseState::InBlock; continue; @@ -4352,8 +5703,114 @@ ParseState::InBlock => { if trimmed_line == "&endblock" { - // NOW execute the block with the stored input file - if let Some(ref input_file) = block_input_file { - let expanded_path = expand_tilde(input_file); - + // NOW execute the block with the stored input file or directory + if let Some(ref input_path) = block_input_file { + let expanded_path = expand_tilde(input_path); + + if block_is_files_mode { + // FILES MODE: iterate through all files in directory + let path = Path::new(&expanded_path); + + if !path.exists() || !path.is_dir() { + return Err(NewbieError::new(&format!( + "Directory not found: {}", expanded_path + ))); + } + + // Read directory entries + let dir_entries = fs::read_dir(&expanded_path) + .map_err(|e| NewbieError::new(&format!( + "Failed to read directory: {}", e + )))?; + + // Collect files into fixed-size array (skip directories and hidden) + let mut files: [Option; MAX_FILES_TO_LIST] = [const { None }; MAX_FILES_TO_LIST]; + let mut file_count = 0; + + for entry_result in dir_entries { + if file_count >= MAX_FILES_TO_LIST { + break; + } + if check_interrupted() { + clear_interrupted(); + break; + } + + let entry = entry_result.map_err(|e| + NewbieError::new(&format!("Error reading entry: {}", e)) + )?; + + let file_name = entry.file_name().to_string_lossy().to_string(); + + // Skip hidden files + if file_name.starts_with('.') { + continue; + } + + // Skip directories + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + continue; + } + + let full_path = path.join(&file_name).to_string_lossy().to_string(); + files[file_count] = Some(full_path); + file_count += 1; + } + + // Sort files alphabetically + files[..file_count].sort_by(|a, b| { + let a_str = a.as_ref().map(|s| s.as_str()).unwrap_or(""); + let b_str = b.as_ref().map(|s| s.as_str()).unwrap_or(""); + a_str.to_lowercase().cmp(&b_str.to_lowercase()) + }); + + // Process each file + for i in 0..file_count { + if check_interrupted() { + clear_interrupted(); + break; + } + + if let Some(ref file_path) = files[i] { + let block_reader = match get_reader(file_path) { + Ok(r) => r, + Err(e) => { + if command.display_output && !command.raw_mode { + eprintln!("Error opening {}: {}", file_path, e); + } + continue; + } + }; + + // Iterate through file lines + for block_line in tolerant_lines(block_reader) { + if check_interrupted() { + clear_interrupted(); + break; + } + + // Set &newbie.line + GLOBAL_VARS.with(|vars| { + vars.borrow_mut().insert("newbie.line".to_string(), block_line.clone()); + }); + + // Execute each stored block line + for j in 0..block_line_count { + if let Some(ref cmd_line) = block_lines[j] { + if let Err(e) = parse_and_execute_line(cmd_line, true) { + if command.display_output && !command.raw_mode { + eprintln!("Error in block: {}", e); + } + } + } + } + } + } + } + + if command.display_output && !command.raw_mode { + println!("Processed {} files", file_count); + } + } else { + // NORMAL MODE: iterate through single file if !Path::new(&expanded_path).exists() { return Err(NewbieError::new(&format!("Input file not found: {}", expanded_path))); @@ -4363,13 +5820,9 @@ // Execute block: iterate through input file - for block_line_result in block_reader.lines() { + for block_line in tolerant_lines(block_reader) { if check_interrupted() { clear_interrupted(); break; } - - let block_line = block_line_result.map_err(|e| - NewbieError::new(&format!("Error reading block input: {}", e)) - )?; // Set &newbie.line @@ -4381,36 +5834,135 @@ for i in 0..block_line_count { if let Some(ref cmd_line) = block_lines[i] { - // Check if this line has a condition (&if or ¬ &if) + // Check if this line has a condition (&if) let trimmed_cmd = cmd_line.trim(); let (should_execute, line_to_execute) = if trimmed_cmd.starts_with("&if ") { - // Extract the condition argument + // Parse new &if syntax: &if [¬] &exists|&empty|&math ... let after_if = trimmed_cmd.strip_prefix("&if ").unwrap(); - // Find first whitespace to get just the condition variable - if let Some(space_pos) = after_if.find(char::is_whitespace) { - let condition = &after_if[..space_pos]; - let should_exec = evaluate_condition(condition, false); - // Keep everything after the condition, preserving spacing - let remaining = after_if[space_pos..].trim_start(); - (should_exec, remaining.to_string()) + + let (negate, after_modifier) = if after_if.starts_with("¬ ") { + (true, after_if.strip_prefix("¬ ").unwrap()) } else { - // No command after condition - (evaluate_condition(after_if.trim(), false), String::new()) - } - } else if trimmed_cmd.starts_with("¬ ") { - // Check if it's ¬ &if - let after_not = trimmed_cmd.strip_prefix("¬ ").unwrap().trim(); - if after_not.starts_with("&if ") { - let after_if = after_not.strip_prefix("&if ").unwrap(); - if let Some(space_pos) = after_if.find(char::is_whitespace) { - let condition = &after_if[..space_pos]; - let should_exec = evaluate_condition(condition, true); - // Keep everything after the condition, preserving spacing - let remaining = after_if[space_pos..].trim_start(); + (false, after_if) + }; + + if after_modifier.starts_with("&exists ") { + let after_exists = after_modifier.strip_prefix("&exists ").unwrap(); + if let Some(space_pos) = after_exists.find(char::is_whitespace) { + let filename = &after_exists[..space_pos]; + let expanded = expand_tilde(filename); + let exists = Path::new(&expanded).exists(); + let should_exec = if negate { !exists } else { exists }; + let remaining = after_exists[space_pos..].trim_start(); (should_exec, remaining.to_string()) } else { - (evaluate_condition(after_if.trim(), true), String::new()) + let expanded = expand_tilde(after_exists.trim()); + let exists = Path::new(&expanded).exists(); + (if negate { !exists } else { exists }, String::new()) + } + } else if after_modifier.starts_with("&empty ") { + let after_empty = after_modifier.strip_prefix("&empty ").unwrap(); + if let Some(space_pos) = after_empty.find(char::is_whitespace) { + let varname = &after_empty[..space_pos]; + let is_empty = if let Some((ns, name)) = parse_variable_reference(varname) { + get_variable(ns, &name).map(|v| v.is_empty()).unwrap_or(true) + } else { + true + }; + let should_exec = if negate { !is_empty } else { is_empty }; + let remaining = after_empty[space_pos..].trim_start(); + (should_exec, remaining.to_string()) + } else { + let varname = after_empty.trim(); + let is_empty = if let Some((ns, name)) = parse_variable_reference(varname) { + get_variable(ns, &name).map(|v| v.is_empty()).unwrap_or(true) + } else { + true + }; + (if negate { !is_empty } else { is_empty }, String::new()) + } + } else if after_modifier.starts_with("&filled ") { + let after_filled = after_modifier.strip_prefix("&filled ").unwrap(); + if let Some(space_pos) = after_filled.find(char::is_whitespace) { + let varname = &after_filled[..space_pos]; + let is_filled = if let Some((ns, name)) = parse_variable_reference(varname) { + get_variable(ns, &name).map(|v| !v.is_empty()).unwrap_or(false) + } else { + false + }; + let should_exec = if negate { !is_filled } else { is_filled }; + let remaining = after_filled[space_pos..].trim_start(); + (should_exec, remaining.to_string()) + } else { + let varname = after_filled.trim(); + let is_filled = if let Some((ns, name)) = parse_variable_reference(varname) { + get_variable(ns, &name).map(|v| !v.is_empty()).unwrap_or(false) + } else { + false + }; + (if negate { !is_filled } else { is_filled }, String::new()) + } + } else if after_modifier.starts_with("&math ") { + let after_math = after_modifier.strip_prefix("&math ").unwrap(); + // Find where the math expression ends (at next &keyword) + let mut math_end = after_math.len(); + for kw in ["&write", "&show", "&run", "©", "&move", "&delete"].iter() { + if let Some(pos) = after_math.find(kw) { + if pos < math_end { + math_end = pos; + } + } + } + let math_expr = after_math[..math_end].trim(); + let remaining = after_math[math_end..].trim_start(); + let result_str = evaluate_math_expression(math_expr).unwrap_or_else(|_| "0".to_string()); + let result: f64 = result_str.parse().unwrap_or(0.0); + let is_true = result != 0.0; + let should_exec = if negate { !is_true } else { is_true }; + (should_exec, remaining.to_string()) + } else if after_modifier.starts_with("&v.") || after_modifier.starts_with("&global.") || + after_modifier.starts_with("&config.") || after_modifier.starts_with("&system.") || + after_modifier.starts_with("&process.") || after_modifier.starts_with("&newbie.") { + // Variable-first syntax: &if &v.varname &filled/&empty + if let Some(space_pos) = after_modifier.find(char::is_whitespace) { + let varname = &after_modifier[..space_pos]; + let after_var = after_modifier[space_pos..].trim_start(); + + if after_var.starts_with("&filled") { + let is_filled = if let Some((ns, name)) = parse_variable_reference(varname) { + get_variable(ns, &name).map(|v| !v.is_empty()).unwrap_or(false) + } else { + false + }; + let should_exec = if negate { !is_filled } else { is_filled }; + let remaining = if after_var.len() > 7 { + after_var[7..].trim_start().to_string() + } else { + String::new() + }; + (should_exec, remaining) + } else if after_var.starts_with("&empty") { + let is_empty = if let Some((ns, name)) = parse_variable_reference(varname) { + get_variable(ns, &name).map(|v| v.is_empty()).unwrap_or(true) + } else { + true + }; + let should_exec = if negate { !is_empty } else { is_empty }; + let remaining = if after_var.len() > 6 { + after_var[6..].trim_start().to_string() + } else { + String::new() + }; + (should_exec, remaining) + } else { + // Variable but unknown test type + (false, String::new()) + } + } else { + // Variable with no test type after it + (false, String::new()) } } else { - (true, trimmed_cmd.to_string()) // ¬ without &if, execute normally + // Unknown test type - don't execute + (false, String::new()) } } else { @@ -4432,8 +5984,10 @@ println!("Block processing complete"); } + } } state = ParseState::Normal; block_input_file = None; + block_is_files_mode = false; block_line_count = 0; block_lines = [const { None }; MAX_BLOCK_LINES]; @@ -4488,9 +6042,6 @@ let mut file_line_num = 0; - for search_line_result in search_reader.lines() { + for search_line in tolerant_lines(search_reader) { file_line_num += 1; - let search_line = search_line_result.map_err(|e| - NewbieError::new(&format!("Error reading file: {}", e)) - )?; if line_matches_pattern(search_line.as_bytes(), &compiled_pattern) { @@ -4661,4 +6212,5 @@ let mut content_end = line.len(); for entry in KEYWORDS.iter() { + // Check for keyword with trailing space if let Some(pos) = after_show.find(&format!(" {} ", entry.name)) { let absolute_pos = show_pos + 6 + pos; @@ -4667,4 +6219,12 @@ } } + // Check for keyword at end of line (no trailing space) + if after_show.ends_with(&format!(" {}", entry.name)) { + let pos = after_show.len() - entry.name.len() - 1; + let absolute_pos = show_pos + 6 + pos; + if absolute_pos < content_end { + content_end = absolute_pos; + } + } } @@ -4678,12 +6238,24 @@ let expanded = expand_show_content(content)?; - // Check if this is a file path that exists + // If the content was a variable reference, just show the expanded value (don't try to read as file) + if content.starts_with('&') { + command.action = Some("show_variable".to_string()); + command.source = Some(expanded.clone()); + command.destination = Some(expanded); + return Ok(ExecutionResult::Continue); + } + + // Content is a direct filename - check if it exists let expanded_path = expand_tilde(&expanded); - if Path::new(&expanded_path).exists() { + let path = Path::new(&expanded_path); + if path.exists() && path.is_file() { // This is a file - show the file command.action = Some("show".to_string()); command.source = Some(expanded); + } else if !path.exists() { + // File doesn't exist - error + return Err(NewbieError::new(&format!("File not found: {}", expanded_path))); } else { - // This is literal text - show the text + // Exists but not a file (directory) - show as text command.action = Some("show_variable".to_string()); command.source = Some(expanded.clone()); @@ -4808,5 +6380,18 @@ // Extract pattern (between &find and &in) - let pattern_str = line[find_pos + 6..in_pos].trim(); + let pattern_start = find_pos + 6; + let pattern_str = if pattern_start <= in_pos { + line[pattern_start..in_pos].trim() + } else { + "" + }; + + // Check for ¬ modifier + let pattern_str = if pattern_str.starts_with("¬ ") { + command.negate_condition = true; + pattern_str[5..].trim() + } else { + pattern_str + }; if pattern_str.is_empty() { @@ -4819,5 +6404,10 @@ let (filepath, output_file) = if let Some(into_pos) = into_pos { // Extract input file (between &in and &into) - let input_file = line[in_pos + 5..into_pos].trim().to_string(); + let input_start = in_pos + 5; + let input_file = if input_start <= into_pos { + line[input_start..into_pos].trim().to_string() + } else { + String::new() + }; // Extract output file (after &into) using shared helper @@ -4971,6 +6561,10 @@ } - // Skip leading whitespace - while pos < pattern_bytes.len() && pattern_bytes[pos] == b' ' { + // Skip leading whitespace at start of line, or one delimiter space otherwise + if pos == 0 { + while pos < pattern_bytes.len() && (pattern_bytes[pos] == b' ' || pattern_bytes[pos] == b'\t') { + pos += 1; + } + } else if pos < pattern_bytes.len() && pattern_bytes[pos] == b' ' { pos += 1; } @@ -5017,8 +6611,9 @@ } - // Check for pattern keywords with numbers: &numbers N, &letters N, etc. + // Check for pattern keywords with numbers: &numbers N, &letters N, &text N, etc. let keyword_checks = [ ("&numbers", PatternComponent::Numbers as fn(usize) -> PatternComponent), ("&letters", PatternComponent::Letters as fn(usize) -> PatternComponent), + ("&text", PatternComponent::Text as fn(usize) -> PatternComponent), ("&spaces", PatternComponent::Space as fn(usize) -> PatternComponent), ("&tabs", PatternComponent::Tab as fn(usize) -> PatternComponent), @@ -5174,5 +6769,10 @@ // Extract pattern (between &capture and &in) - let pattern_str = line[capture_pos + 9..in_pos].trim(); + let pattern_start = capture_pos + 9; + let pattern_str = if pattern_start <= in_pos { + line[pattern_start..in_pos].trim() + } else { + "" + }; if pattern_str.is_empty() { @@ -5186,5 +6786,10 @@ let (filepath, output_file) = if let Some(into_pos) = into_pos { // Extract input file (between &in and &into) - let input_file = line[in_pos + 5..into_pos].trim().to_string(); + let input_start = in_pos + 5; + let input_file = if input_start <= into_pos { + line[input_start..into_pos].trim().to_string() + } else { + String::new() + }; // Extract output file (after &into) using shared helper @@ -5195,5 +6800,10 @@ } else if let Some(write_pos) = write_pos { // Extract input file (between &in and &write) - let input_file = line[in_pos + 5..write_pos].trim().to_string(); + let input_start = in_pos + 5; + let input_file = if input_start <= write_pos { + line[input_start..write_pos].trim().to_string() + } else { + String::new() + }; (input_file, None) } else { @@ -5254,4 +6864,20 @@ let expanded_path = expand_tilde(dir_path); + // Handle &files &size filename - return just the byte count + if command.files_size_only { + let path = Path::new(&expanded_path); + if !path.exists() { + return Err(NewbieError::new(&format!("File not found: {}", expanded_path))); + } + let metadata = fs::metadata(&expanded_path) + .map_err(|e| NewbieError::new(&format!("Cannot get file size: {}", e)))?; + let size = metadata.len(); + + if command.display_output { + println!("{}", size); + } + return Ok(()); + } + // Check if this is a glob pattern let has_glob = expanded_path.contains('*') || expanded_path.contains('?') || expanded_path.contains('['); @@ -5340,65 +6966,4 @@ } -fn handle_set(args: &[&str], command: &mut Command) -> Result> { - if args.len() < 2 { - return Err(NewbieError::new("&set requires variable and value: &set &v.name value")); - } - - let var_ref = args[0]; - - let var_value = if args.len() >= 3 && args[1] == "=" { - let mut value = String::new(); - for (i, &arg) in args[2..].iter().enumerate() { - if i > 0 { - value.push(' '); - } - value.push_str(arg); - } - value - } else { - let mut value = String::new(); - for (i, &arg) in args[1..].iter().enumerate() { - if i > 0 { - value.push(' '); - } - value.push_str(arg); - } - value - }; - - if let Some((namespace, name)) = parse_variable_reference(var_ref) { - set_variable(namespace, &name, &var_value)?; - command.action = Some("set_variable".to_string()); - command.source = Some(format!("{}={}", var_ref, var_value)); - } else { - return Err(NewbieError::new(&format!("Invalid variable reference: {}", var_ref))); - } - - Ok(ExecutionResult::Stop) -} - -fn handle_get(args: &[&str], command: &mut Command) -> Result> { - if args.len() != 1 { - return Err(NewbieError::new("&get requires one variable reference: &get &v.name")); - } - - command.display_output = true; - - let var_ref = args[0]; - - if let Some((namespace, name)) = parse_variable_reference(var_ref) { - command.action = Some("get_variable".to_string()); - command.source = Some(var_ref.to_string()); - - if let Some(value) = get_variable(namespace, &name) { - command.destination = Some(value); - } - } else { - return Err(NewbieError::new(&format!("Invalid variable reference: {}", var_ref))); - } - - Ok(ExecutionResult::Stop) -} - fn handle_vars(args: &[&str], command: &mut Command) -> Result> { command.display_output = true; @@ -5491,4 +7056,14 @@ } +fn handle_create(args: &[&str], command: &mut Command) -> Result> { + if args.len() != 1 { + return Err(NewbieError::new("&create requires exactly one path argument")); + } + + command.action = Some("create".to_string()); + command.source = Some(args[0].to_string()); + Ok(ExecutionResult::Stop) +} + fn execute_delete_command(file_path: &str, command: &Command) -> Result<(), Box> { let expanded_path = expand_tilde(file_path); @@ -5503,5 +7078,5 @@ if !path.exists() { - return Err(NewbieError::new(&format!("Path not found: {}", expanded_path))); + return Err(NewbieError::new(&format!("Not found: {}", expanded_path))); } @@ -5623,4 +7198,95 @@ } +fn execute_create_command(path: &str, command: &Command) -> Result<(), Box> { + let expanded_path = expand_tilde(path); + + // Trailing slash means directory, otherwise file + let is_directory = path.ends_with('/'); + + if is_directory { + // Create directory tree (like mkdir -p) + if command.admin_mode { + let mut sudo_cmd = StdCommand::new("sudo"); + sudo_cmd.arg("mkdir").arg("-p").arg(&expanded_path); + + if !command.display_output { + sudo_cmd.stdout(Stdio::null()).stderr(Stdio::null()); + } + + let status = sudo_cmd.status().map_err(|e| + NewbieError::new(&format!("Failed to execute sudo mkdir: {}", e)) + )?; + + if !status.success() { + return Err(NewbieError::new(&format!("Create directory failed with exit code: {}", + status.code().unwrap_or(-1)))); + } + + let _ = StdCommand::new("sudo").arg("-k").status(); + } else { + fs::create_dir_all(&expanded_path).map_err(|e| + NewbieError::new(&format!("Failed to create directory {}: {}", expanded_path, e)) + )?; + } + + if command.display_output && !command.raw_mode { + println!("Created directory {}", path); + } + } else { + // Create empty file (like touch) + // First ensure parent directory exists + let file_path = Path::new(&expanded_path); + if let Some(parent) = file_path.parent() { + if !parent.as_os_str().is_empty() && !parent.exists() { + if command.admin_mode { + let mut sudo_cmd = StdCommand::new("sudo"); + sudo_cmd.arg("mkdir").arg("-p").arg(parent); + sudo_cmd.stdout(Stdio::null()).stderr(Stdio::null()); + let _ = sudo_cmd.status(); + } else { + fs::create_dir_all(parent).map_err(|e| + NewbieError::new(&format!("Failed to create parent directory: {}", e)) + )?; + } + } + } + + if command.admin_mode { + let mut sudo_cmd = StdCommand::new("sudo"); + sudo_cmd.arg("touch").arg(&expanded_path); + + if !command.display_output { + sudo_cmd.stdout(Stdio::null()).stderr(Stdio::null()); + } + + let status = sudo_cmd.status().map_err(|e| + NewbieError::new(&format!("Failed to execute sudo touch: {}", e)) + )?; + + if !status.success() { + return Err(NewbieError::new(&format!("Create file failed with exit code: {}", + status.code().unwrap_or(-1)))); + } + + let _ = StdCommand::new("sudo").arg("-k").status(); + } else { + // Create or update file (touch behavior) + OpenOptions::new() + .create(true) + .write(true) + .open(&expanded_path) + .map_err(|e| + NewbieError::new(&format!("Failed to create file {}: {}", expanded_path, e)) + )?; + } + + if command.display_output && !command.raw_mode { + println!("Created {}", path); + } + } + + Ok(()) +} + fn get_history_path() -> Option { dirs::data_dir().map(|mut path| { @@ -5733,15 +7399,24 @@ // Set up Ctrl-C handler setup_ctrlc_handler()?; - // If script file provided, run it and exit - if let Some(script_path) = env::args().nth(1) { - let mut command = Command::new(); - command.display_output = true; - return execute_newbie_script(&script_path, &command); - } - if let Some(script_path) = env::args().nth(1) { - let mut command = Command::new(); - command.display_output = true; - return execute_newbie_script(&script_path, &command); + // Collect all arguments after the program name + let args: Vec = env::args().skip(1).collect(); + + if !args.is_empty() { + // Check if first argument is a script file (exists on disk) + let first_arg = &args[0]; + let expanded_path = expand_tilde(first_arg); + + if Path::new(&expanded_path).exists() && !first_arg.starts_with('&') { + // It's a script file - run it silently + let command = Command::new(); + return execute_newbie_script(&expanded_path, &command); + } else { + // Join all arguments as a single command line + // This allows: newbie &find pattern &in &pipeline + // Where bash splits on spaces but we rejoin them + let command_line = args.join(" "); + return parse_and_execute_line(&command_line, true); + } } @@ -5754,5 +7429,5 @@ } - println!("Newbie Shell v1.0 - Text pre-processing and shell support"); + println!("Newbie Shell v1.0.8 - Text pre-processing and shell support"); println!("Type '&exit' to quit, or use Ctrl+D"); println!(" For assistance, type &guide"); Binary files /var/lib/copr-rpmbuild/results/newbie/upstream-unpacked/Source0/Newbie-1.0.8/src/user_guide.txt.bz2 and /var/lib/copr-rpmbuild/results/newbie/srpm-unpacked/Newbie-1.0.8.tar.gz-extract/Newbie-1.0.8/src/user_guide.txt.bz2 differ