diff --git a/crates/cli/src/help.rs b/crates/cli/src/help.rs index 9fafbbb5b..4dcee91d6 100644 --- a/crates/cli/src/help.rs +++ b/crates/cli/src/help.rs @@ -1,17 +1,30 @@ use indexmap::IndexMap; -use std::iter::Peekable; +use std::{ + iter::{self, Peekable}, + rc::Rc, +}; const HELP_RESULT_STR: &str = "➝ "; const HELP_INDENT: usize = 2; struct HelpEntry { - name: String, - help: String, + // The entry's user-displayed name + name: Rc, + // The entry's contents + help: Rc, + // Additional keywords that should be checked when searching + keywords: Vec>, + // Names of related topics to show in the 'See also' section + see_also: Vec>, } pub struct Help { - help_map: IndexMap, - module_names: Vec, + // All help entries, keys are lower_snake_case + help_map: IndexMap, HelpEntry>, + // The list of guide topics + guide_topics: Vec>, + // The list of core library module names + module_names: Vec>, } impl Help { @@ -26,24 +39,24 @@ impl Help { let guide_files = [ include_doc!("language/basics.md"), - include_doc!("language/conditional_expressions.md"), - include_doc!("language/core_library.md"), - include_doc!("language/errors.md"), + include_doc!("language/strings.md"), include_doc!("language/functions.md"), - include_doc!("language/functions_advanced.md"), - include_doc!("language/generators.md"), - include_doc!("language/iterators.md"), include_doc!("language/lists.md"), - include_doc!("language/loops.md"), + include_doc!("language/tuples.md"), include_doc!("language/maps.md"), + include_doc!("language/core_library.md"), + include_doc!("language/iterators.md"), + include_doc!("language/value_unpacking.md"), + include_doc!("language/conditional_expressions.md"), + include_doc!("language/loops.md"), + include_doc!("language/ranges.md"), + include_doc!("language/functions_advanced.md"), + include_doc!("language/generators.md"), include_doc!("language/meta_maps.md"), + include_doc!("language/errors.md"), + include_doc!("language/testing.md"), include_doc!("language/modules.md"), include_doc!("language/prelude.md"), - include_doc!("language/ranges.md"), - include_doc!("language/strings.md"), - include_doc!("language/testing.md"), - include_doc!("language/tuples.md"), - include_doc!("language/value_unpacking.md"), ]; let reference_files = [ @@ -62,6 +75,7 @@ impl Help { let mut result = Self { help_map: IndexMap::new(), + guide_topics: Vec::new(), module_names: Vec::new(), }; @@ -79,8 +93,8 @@ impl Help { pub fn get_help(&self, search: Option<&str>) -> String { match search { Some(search) => { - let search = text_to_key(search); - match self.help_map.get(&search) { + let search_key = text_to_key(search); + match self.help_map.get(&search_key) { Some(entry) => { let mut help = format!( "{indent}{name}\n{indent}{underline}{help}", @@ -90,33 +104,31 @@ impl Help { help = entry.help ); - let sub_match = format!("{search}."); - let match_level = sub_match.chars().filter(|&c| c == '.').count(); - let sub_entries = self - .help_map + let see_also: Vec<_> = entry + .see_also .iter() - .filter(|(key, _)| { - key.starts_with(&sub_match) - && key.chars().filter(|&c| c == '.').count() <= match_level - }) - .collect::>(); + .chain(self.help_map.iter().filter_map(|(key, search_entry)| { + if key.contains(search_key.as_ref()) + && !entry.see_also.contains(&search_entry.name) + && search_entry.name != entry.name + { + Some(&search_entry.name) + } else { + None + } + })) + .collect(); - if !sub_entries.is_empty() { - let sub_entry_prefix = format!("{}.", entry.name); + if !see_also.is_empty() { help += " -------- - Help is available for the following sub-topics:\n "; + See also:"; - for (i, (_, sub_entry)) in sub_entries.iter().enumerate() { - if i > 0 { - help.push_str(", "); - } - - help.push_str( - sub_entry.name.strip_prefix(&sub_entry_prefix).unwrap(), - ); + for see_also_entry in see_also.iter() { + help.push_str("\n - "); + help.push_str(see_also_entry); } } @@ -126,7 +138,13 @@ impl Help { let matches = self .help_map .iter() - .filter(|(key, _)| key.contains(&search)) + .filter(|(key, value)| { + key.contains(search_key.as_ref()) + || value + .keywords + .iter() + .any(|keyword| keyword.contains(search_key.as_ref())) + }) .collect::>(); match matches.as_slice() { @@ -136,7 +154,7 @@ impl Help { let mut help = String::new(); help.push_str(" More than one match found: "); for (_, HelpEntry { name, .. }) in matches { - help.push_str("\n "); + help.push_str("\n - "); help.push_str(name); } help @@ -149,43 +167,28 @@ impl Help { let mut help = " To get help for a topic, run `help ` (e.g. `help strings`). - To look up the core library documentation, run `help .` (e.g. `help map.keys`). - - Help is available for the following topics: - " - .to_string(); - - let mut topics = self - .help_map - .keys() - // Filter out core library entries - .filter(|key| !key.contains('.') && !self.module_names.contains(key)) - .collect::>(); - // Sort the topics into alphabetical order - topics.sort(); - // Bump topics starting with non-alphnumeric characters to the end of the list - topics.sort_by_key(|name| !name.chars().next().unwrap().is_alphanumeric()); - - for (i, topic) in topics.iter().enumerate() { - if i > 0 { - help.push_str(", "); - } + To look up the core library documentation, run `help .` (e.g. `help map.keys`)." + .to_string(); - help.push_str(topic); + help.push_str( + " + + Help is available for the following language guide topics:", + ); + + for guide_topic in self.guide_topics.iter() { + help.push_str("\n - "); + help.push_str(guide_topic); } help.push_str( " - Help is available for the following core library modules: - ", + Help is available for the following core library modules:", ); - for (i, module_name) in self.module_names.iter().enumerate() { - if i > 0 { - help.push_str(", "); - } - + for module_name in self.module_names.iter() { + help.push_str("\n - "); help.push_str(module_name); } @@ -198,25 +201,48 @@ impl Help { let mut parser = pulldown_cmark::Parser::new(markdown).peekable(); // Consume the module overview section - let (file_name, help) = consume_help_section(&mut parser, None); - if !help.trim().is_empty() { - self.help_map.insert( - text_to_key(&file_name), - HelpEntry { - name: file_name, - help, - }, - ); - } + let topic = consume_help_section(&mut parser, None); + // We should avoid top-level topics without a body + debug_assert!( + !topic.contents.trim().is_empty(), + "Missing contents for {}", + topic.name + ); // Add sub-topics + let mut sub_topics = Vec::new(); while parser.peek().is_some() { - let (entry_name, help) = consume_help_section(&mut parser, None); + sub_topics.push(consume_help_section(&mut parser, None)); + } + + let see_also = sub_topics + .iter() + .flat_map(|sub_topic| iter::once(&sub_topic.name).chain(sub_topic.sub_sections.iter())) + .cloned() + .collect(); + self.help_map.insert( + text_to_key(&topic.name), + HelpEntry { + name: topic.name.clone(), + help: topic.contents, + see_also, + keywords: vec![], + }, + ); + self.guide_topics.push(topic.name.clone()); + + for sub_topic in sub_topics { self.help_map.insert( - text_to_key(&entry_name), + text_to_key(&sub_topic.name), HelpEntry { - name: entry_name, - help, + name: sub_topic.name, + help: sub_topic.contents, + keywords: sub_topic + .sub_sections + .iter() + .map(|sub_section| text_to_key(sub_section)) + .collect(), + see_also: vec![topic.name.clone()], }, ); } @@ -225,53 +251,72 @@ impl Help { fn add_help_from_reference(&mut self, markdown: &str) { let mut parser = pulldown_cmark::Parser::new(markdown).peekable(); - let (module_name, help) = consume_help_section(&mut parser, None); - if !help.trim().is_empty() { + let help_section = consume_help_section(&mut parser, None); + + // Consume each module entry + let mut entry_names = Vec::new(); + while parser.peek().is_some() { + let module_entry = consume_help_section(&mut parser, Some(&help_section.name)); self.help_map.insert( - text_to_key(&module_name), + text_to_key(&module_entry.name), HelpEntry { - name: module_name.clone(), - help, + name: module_entry.name.clone(), + help: module_entry.contents, + see_also: Vec::new(), + keywords: vec![], }, ); + entry_names.push(module_entry.name); } - self.module_names.push(module_name.clone()); - // Consume each module entry - while parser.peek().is_some() { - let (entry_name, help) = consume_help_section(&mut parser, Some(&module_name)); + if !help_section.contents.trim().is_empty() { self.help_map.insert( - text_to_key(&entry_name), + text_to_key(&help_section.name), HelpEntry { - name: entry_name, - help, + name: help_section.name.clone(), + help: help_section.contents, + see_also: entry_names, + keywords: vec![], }, ); } + self.module_names.push(help_section.name.clone()); } } -fn text_to_key(text: &str) -> String { - text.trim().to_lowercase().replace(' ', "_") +fn text_to_key(text: &str) -> Rc { + text.trim().to_lowercase().replace(' ', "_").into() +} + +struct HelpSection { + name: Rc, + contents: Rc, + sub_sections: Vec>, +} + +enum ParsingMode { + Any, + Section, + SubSection, + Code, + TypeDeclaration, } fn consume_help_section( parser: &mut Peekable, module_name: Option<&str>, -) -> (String, String) { +) -> HelpSection { use pulldown_cmark::{CodeBlockKind, Event::*, HeadingLevel, Tag::*}; let mut section_level = None; let mut section_name = String::new(); + let mut sub_section_name = String::new(); + let mut sub_sections = Vec::new(); let indent = " ".repeat(HELP_INDENT); let mut result = indent.clone(); let mut list_indent = 0; - let mut in_section_heading = false; - let mut heading_start = 0; - let mut first_heading = true; - let mut in_koto_code = false; - let mut in_type_declaration = false; + let mut parsing_mode = ParsingMode::Any; while let Some(peeked) = parser.peek() { match peeked { @@ -287,25 +332,25 @@ fn consume_help_section( } Some(_) => { // Start a new subsection + parsing_mode = ParsingMode::SubSection; + sub_section_name.clear(); result.push_str("\n\n"); } None => { - in_section_heading = true; + parsing_mode = ParsingMode::Section; section_level = Some(*level); } } - heading_start = result.len(); } End(Heading(_, _, _)) => { - if !first_heading { - let heading_length = result.len() - heading_start; + if matches!(parsing_mode, ParsingMode::SubSection) { + sub_sections.push(sub_section_name.as_str().into()); result.push('\n'); - for _ in 0..heading_length { + for _ in 0..sub_section_name.len() { result.push('-'); } } - in_section_heading = false; - first_heading = false; + parsing_mode = ParsingMode::Any; } Start(Link(_type, _url, title)) => result.push_str(title), End(Link(_, _, _)) => {} @@ -329,23 +374,24 @@ fn consume_help_section( Start(CodeBlock(CodeBlockKind::Fenced(lang))) => { result.push_str("\n\n"); match lang.split(',').next() { - Some("koto") => in_koto_code = true, - Some("kototype") => in_type_declaration = true, + Some("koto") => parsing_mode = ParsingMode::Code, + Some("kototype") => parsing_mode = ParsingMode::TypeDeclaration, _ => {} } } - End(CodeBlock(_)) => { - in_koto_code = false; - in_type_declaration = false; - } + End(CodeBlock(_)) => parsing_mode = ParsingMode::Any, Start(Emphasis) => result.push('_'), End(Emphasis) => result.push('_'), Start(Strong) => result.push('*'), End(Strong) => result.push('*'), - Text(text) => { - if in_section_heading { - section_name.push_str(text); - } else if in_koto_code { + Text(text) => match parsing_mode { + ParsingMode::Any => result.push_str(text), + ParsingMode::Section => section_name.push_str(text), + ParsingMode::SubSection => { + sub_section_name.push_str(text); + result.push_str(text); + } + ParsingMode::Code => { for (i, line) in text.split('\n').enumerate() { if i == 0 { result.push('|'); @@ -358,16 +404,15 @@ fn consume_help_section( ); result.push_str(&processed_line); } - } else if in_type_declaration { + } + ParsingMode::TypeDeclaration => { result.push('`'); result.push_str(text.trim_end()); result.push('`'); - } else { - result.push_str(text); } - } + }, Code(code) => { - if in_section_heading { + if matches!(parsing_mode, ParsingMode::Section) { section_name.push_str(code); } else { result.push('`'); @@ -386,7 +431,11 @@ fn consume_help_section( if let Some(module_name) = module_name { section_name = format!("{module_name}.{section_name}"); } - let result = result.replace('\n', &format!("\n{indent}")); + let contents = result.replace('\n', &format!("\n{indent}")); - (section_name, result) + HelpSection { + name: section_name.into(), + contents: contents.into(), + sub_sections, + } } diff --git a/crates/lexer/src/lexer.rs b/crates/lexer/src/lexer.rs index 9bc915420..6d2123a2b 100644 --- a/crates/lexer/src/lexer.rs +++ b/crates/lexer/src/lexer.rs @@ -692,7 +692,7 @@ fn is_octal_digit(c: char) -> bool { } fn is_hex_digit(c: char) -> bool { - matches!(c, '0'..='9' | 'a'..='f' | 'A'..='F') + c.is_ascii_hexdigit() } fn is_whitespace(c: char) -> bool { diff --git a/crates/runtime/src/core_lib/iterator/adaptors.rs b/crates/runtime/src/core_lib/iterator/adaptors.rs index 3c1af5931..39ad6ffe8 100644 --- a/crates/runtime/src/core_lib/iterator/adaptors.rs +++ b/crates/runtime/src/core_lib/iterator/adaptors.rs @@ -860,10 +860,7 @@ impl Iterator for TakeWhile { return None; } - let Some(iter_output) = self.iter.next() else { - return None; - }; - + let iter_output = self.iter.next()?; let predicate = self.predicate.clone(); let predicate_result = match &iter_output { Output::Value(value) => self diff --git a/docs/language/basics.md b/docs/language/basics.md index 75fdb9379..14d43fd46 100644 --- a/docs/language/basics.md +++ b/docs/language/basics.md @@ -1,7 +1,5 @@ # Language Basics -## Koto Programs - Koto programs contain a series of expressions that are evaluated by Koto's runtime. For example, this program asks for the user's name and then offers them a @@ -14,7 +12,7 @@ print "Hi there, $name!" ``` Try placing the above example in a file named `hello.koto`, and then running -`koto hello.koto`. +`koto hello.koto`, or entering the expressions one at a time in the REPL. ## Comments diff --git a/docs/language/conditional_expressions.md b/docs/language/conditional_expressions.md index 1e00b9e22..8cda1d37c 100644 --- a/docs/language/conditional_expressions.md +++ b/docs/language/conditional_expressions.md @@ -1,5 +1,7 @@ # Conditional Expressions +Koto includes several ways of producing values that depend on _conditions_. + ## if `if` expressions come in two flavours; single-line: diff --git a/docs/language/functions_advanced.md b/docs/language/functions_advanced.md index 74b79b6bc..3ea0ee4c8 100644 --- a/docs/language/functions_advanced.md +++ b/docs/language/functions_advanced.md @@ -1,5 +1,27 @@ # Advanced Functions +Functions in Koto stored by the runtime as values and can hold internal captured state. + +If a value is accessed in a function that wasn't assigned locally, +then the value is copied into the function (or _captured_) when it's created. + +```koto +x = 1 + +# x is assigned outside the function, +# so it gets captured when it's created. +f = |n| n + x + +# Reassigning x here doesn't modify the value +# of x that was captured when f was created. +x = 100 + +print! f 2 +check! 3 +``` + +It's worth noting that this behavior is different to many other scripting languages, where captures are often taken by _reference_ rather than by _value_. + ## Optional Arguments When calling a function, any missing arguments will be replaced by `null`. @@ -126,20 +148,3 @@ check! (('foo_a', 1), ('foo_b', 3)) ## Captured Values -If a value is accessed in a function that wasn't assigned locally, -then the value is copied into the function (or _captured_) when it's created. - -```koto -x = 1 - -# x is assigned outside the function, -# so it gets captured when it's created. -f = |n| n + x - -# Reassigning x here doesn't modify the value -# of x that was captured when f was created. -x = 100 - -print! f 2 -check! 3 -``` diff --git a/docs/language/loops.md b/docs/language/loops.md index 2364b5aab..0d4eed6d3 100644 --- a/docs/language/loops.md +++ b/docs/language/loops.md @@ -1,5 +1,7 @@ # Loops +Koto includes several ways of evaluating expressions repeatedly in a loop. + ## for `for` loops can be used to iterate over any iterable value. diff --git a/docs/language/modules.md b/docs/language/modules.md index a2c21b264..7226204b2 100644 --- a/docs/language/modules.md +++ b/docs/language/modules.md @@ -1,5 +1,7 @@ # Modules +Koto includes a module system that helps you to organize and re-use your code when your program grows too large for a single file. + ## `import` Module items can be brought into the current scope using `import`. diff --git a/docs/language/ranges.md b/docs/language/ranges.md index 26931791d..db4ac4dde 100644 --- a/docs/language/ranges.md +++ b/docs/language/ranges.md @@ -25,7 +25,25 @@ print! r.contains 200 check! true ``` -Ranges are iterable, so can be used in for loops, and with the `iterator` module. +If a value is missing from either side of the range operator then an _unbounded_ +range is created. + +```koto +r = 10.. +print! r.start() +check! 10 +print! r.end() +check! null + +r = ..=100 +print! r.start() +check! null +print! r.end() +check! 100 +``` + +_Bounded_ ranges are iterable, so can be used in for loops, and with the +`iterator` module. ```koto for x in 1..=3 @@ -38,3 +56,28 @@ print! (0..5).to_list() check! [0, 1, 2, 3, 4] ``` +## Slices + +Ranges can be used to create a _slice_ of a container's data. + +```koto +x = (10, 20, 30, 40, 50) +print! x[1..=3] +check! (20, 30, 40) +``` + +For tuples and strings, slices share the original container's data, which +avoids making copies of the elements in the slice. For lists (which contain +mutable data), copies of the slices elements are made. + +If a range doesn't have a defined start, then the slice starts from the +beginning of the container's data. Similarly, if a range doesn't have a defined +end, then the slice includes elements up to the end of the container's data. + +```koto +z = 'Hëllø' +print! z[..2] +check! Hë +print! z[2..] +check! llø +``` diff --git a/docs/language/strings.md b/docs/language/strings.md index e74642541..c2420aaa9 100644 --- a/docs/language/strings.md +++ b/docs/language/strings.md @@ -54,7 +54,7 @@ print! '2 plus 3 is ${2 + 3}.' check! 2 plus 3 is 5. ``` -## String Escape codes +## String Escape Codes Strings can contain the following escape codes to define special characters, all of which start with a `\`. @@ -79,7 +79,7 @@ print! 'Hi \u{1F44B}' check! Hi 👋 ``` -## Single or double quotes +## Single or Double Quotes Whether you use `'` or `"` for your strings doesn't make a difference, except that you can use the other quote character freely in the string without having to escape it with `\`. diff --git a/docs/language/testing.md b/docs/language/testing.md index 951a0ca5a..3065a7dbb 100644 --- a/docs/language/testing.md +++ b/docs/language/testing.md @@ -1,8 +1,11 @@ # Testing +Koto includes some features that help you to automatically check that your code is behaving as you expect. + ## Assertions -A collection of [assertion functions](../../core/test) are available. +A collection of assertion functions are available in the [`test` core library module](../../core/test), +which are included by default in the [prelude](./prelude). ```koto try @@ -22,6 +25,9 @@ check! An assertion failed Tests can be organized in a Map by defining `@test` functions. +The runtime can be configured to automatically run tests when importing modules, +e.g. the CLI will run module tests when the `--import_tests` option is used. + The tests can then be run with [`test.run_tests`](../../core/test#run-tests). ```koto diff --git a/docs/language/tuples.md b/docs/language/tuples.md index 312826220..090ac7518 100644 --- a/docs/language/tuples.md +++ b/docs/language/tuples.md @@ -33,6 +33,8 @@ print! b check! (1, 2, 3, 4, 5, 6) ``` +## Creating Empty or Single-Value Tuples + To create an empty Tuple, or a Tuple with a single entry, use a trailing `,` inside the parentheses. ```koto @@ -51,6 +53,8 @@ print! (1,) check! (1) ``` +## Tuple Mutability + Although Tuples have a fixed structure, mutable values in a Tuple (e.g. Lists and Maps) can still be modified. ```koto