diff --git a/.config/nextest.toml b/.config/nextest.toml
new file mode 100644
index 00000000..f9fd128a
--- /dev/null
+++ b/.config/nextest.toml
@@ -0,0 +1,40 @@
+# The CodeChat Editor, a word processor for software developers.
Copyright
+# (C) 2024-2026 Bryan A. Jones.
+#
+# This file is part of the CodeChat Editor.
+#
+# The CodeChat Editor is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option) any
+# later version.
+#
+# The CodeChat Editor is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# the CodeChat Editor. If not, see
+# [http://www.gnu.org/licenses](http://www.gnu.org/licenses).
+#
+# `nextest.toml` -- a cargo-nextest configuration file
+# ====================================================
+#
+# See the [docs](https://nexte.st/docs/configuration/) for this file. nextest
+# reads it only from `.config/` in the workspace root.
+#
+# Browser-backed tests
+# --------------------
+#
+# The `overall` test binary (`server/tests/overall.rs`, which gathers the
+# `overall_*.rs` modules) drives a browser through WebDriver. Overlapping
+# WebDriver sessions are unreliable on macOS CI, so run these tests one at a
+# time. nextest runs each test in its own process; a
+# [test group](https://nexte.st/docs/configuration/test-groups/) limits how many
+# of those processes run at once, while other tests still run in parallel.
+[test-groups]
+web-driver = { max-threads = 1 }
+
+[[profile.default.overrides]]
+filter = "binary_id(codechat-editor-server::overall)"
+test-group = "web-driver"
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 8ca65126..14e15d7e 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -58,5 +58,4 @@ jobs:
- name: Run tests
run: |
rustup update
- #
bt (build tool) script
Misc topics
-----------
diff --git a/docs/style_guide.cpp b/docs/style_guide.cpp
index 6fc6c4fa..8172fa23 100644
--- a/docs/style_guide.cpp
+++ b/docs/style_guide.cpp
@@ -110,6 +110,12 @@ class LedBlinker {
);
};
+// Keep comments in source files short. Place specifications or long
+// documentation for a file in an accompanying Markdown file, and refer to it
+// following the file's title. For example, `cache.rs` refers to
+// `cache-spec.md`, which gives the edited".to_string(),
+ }],
+ }),
+ version,
+ }),
+ }),
+ },
+ )
+ .await;
+}
+
+/// Send a success response on one websocket, then check that the Server
+/// forwards it to the other.
+async fn send_and_check_result most".to_string(),
+ })],
+ version: 0.0,
+ }),
+ version: 2.0,
+ }),
+ }),
+ }
+ );
+ send_and_check_result(&mut ws_client, &mut ws_ide, ide_id).await;
+
+ // Fail again, so that the IDE holds source newer than the Client's. Since
+ // the last update translated, this reports the error to the Client again.
+ ide_id += MESSAGE_ID_INCREMENT;
+ send_ide_update(&mut ws_ide, ide_id, &file_path_str, untranslatable, 3.0).await;
+ assert_eq!(read_message(&mut ws_ide).await, ide_success(ide_id));
+ assert_eq!(read_message(&mut ws_client).await, client_error(ide_id));
+
+ // The Client can still report its cursor position ...
+ let mut client_id = INITIAL_CLIENT_MESSAGE_ID + MESSAGE_ID_INCREMENT;
+ send_message(
+ &mut ws_client,
+ &EditorMessage {
+ id: client_id,
+ message: EditorMessageContents::Update(UpdateMessageContents {
+ file_path: file_path_str.clone(),
+ cursor_position: None,
+ scroll_position: None,
+ is_re_translation: false,
+ contents: None,
+ }),
+ },
+ )
+ .await;
+ let em = read_message(&mut ws_ide).await;
+ assert_eq!(em.id, client_id);
+ cast!(em.message, EditorMessageContents::Update);
+ send_and_check_result(&mut ws_ide, &mut ws_client, client_id).await;
+
+ // ... but its edits are rejected. If one reached the IDE, then the next
+ // read from `ws_ide` below would fail, since it would return this edit.
+ client_id += MESSAGE_ID_INCREMENT;
+ send_client_edit(&mut ws_client, client_id, &file_path_str, 4.0).await;
+ assert_eq!(
+ read_message(&mut ws_client).await,
+ EditorMessage {
+ id: client_id,
+ message: EditorMessageContents::Result(Err(ResultErrTypes::IgnoredClientEdit(
+ translation_error.clone()
+ ))),
+ }
+ );
+
+ // The Client now holds text which the Server doesn't, so the next update
+ // which translates contains the full text.
+ ide_id += MESSAGE_ID_INCREMENT;
+ send_ide_update(&mut ws_ide, ide_id, &file_path_str, "# newest", 5.0).await;
+ assert_eq!(
+ read_message(&mut ws_client).await,
+ EditorMessage {
+ id: ide_id,
+ message: EditorMessageContents::Update(UpdateMessageContents {
+ file_path: file_path_str.clone(),
+ cursor_position: None,
+ scroll_position: None,
+ is_re_translation: false,
+ contents: Some(CodeChatForWeb {
+ metadata: SourceFileMetadata {
+ mode: "python".to_string(),
+ },
+ source: CodeMirrorDiffable::Plain(CodeMirror {
+ doc: "\n".to_string(),
+ doc_blocks: vec![CodeMirrorDocBlock {
+ from: 0,
+ to: 1,
+ indent: String::new(),
+ delimiter: "#".to_string(),
+ contents: " newest".to_string(),
+ }],
+ }),
+ version: 5.0,
+ }),
+ }),
+ }
+ );
+ send_and_check_result(&mut ws_client, &mut ws_ide, ide_id).await;
+
+ // With the IDE's source translating again, edits from the Client reach it.
+ client_id += MESSAGE_ID_INCREMENT;
+ send_client_edit(&mut ws_client, client_id, &file_path_str, 6.0).await;
+ let em = read_message(&mut ws_ide).await;
+ assert_eq!(em.id, client_id);
+ cast!(em.message, EditorMessageContents::Update);
+ send_and_check_result(&mut ws_ide, &mut ws_client, client_id).await;
+
+ check_logger_errors(0);
+ // Report any errors produced when removing the temporary directory.
+ temp_dir.close().unwrap();
+}
+
/// Send an `Update` message from the Client.
#[actix_web::test]
async fn test_vscode_ide_websocket6() {
@@ -908,7 +1209,8 @@ async fn test_vscode_ide_websocket4() {
id: INITIAL_CLIENT_MESSAGE_ID,
message: EditorMessageContents::CurrentFile(
format!(
- "http://localhost:{IP_PORT}/vsc/fs/{connection_id}/{}",
+ "http://localhost:{}/vsc/fs/{connection_id}/{}",
+ *IP_PORT,
file_path.to_slash().unwrap()
),
None,
@@ -946,7 +1248,8 @@ async fn test_vscode_ide_websocket4() {
// Get the file itself.
assert_eq!(
minreq::get(format!(
- "http://localhost:{IP_PORT}/vsc/fs/{connection_id}/{}/{}",
+ "http://localhost:{}/vsc/fs/{connection_id}/{}/{}",
+ *IP_PORT,
test_dir_thread.to_slash().unwrap(),
// On Windows, send incorrect case for this file; the server
// should correct it.
@@ -1039,7 +1342,8 @@ async fn test_vscode_ide_websocket4() {
let join_handle = thread::spawn(move || {
assert_eq!(
minreq::get(format!(
- "http://localhost:{IP_PORT}/vsc/fs/{connection_id}/{}/toc.md",
+ "http://localhost:{}/vsc/fs/{connection_id}/{}/toc.md",
+ *IP_PORT,
test_dir_thread.to_slash().unwrap()
))
.send()
@@ -1173,7 +1477,8 @@ async fn test_vscode_ide_websocket4a() {
id: INITIAL_CLIENT_MESSAGE_ID,
message: EditorMessageContents::CurrentFile(
format!(
- "http://localhost:{IP_PORT}/vsc/fs/{connection_id}/{}",
+ "http://localhost:{}/vsc/fs/{connection_id}/{}",
+ *IP_PORT,
file_path.to_slash().unwrap()
),
None,
@@ -1216,7 +1521,8 @@ async fn test_vscode_ide_websocket4a() {
let join_handle = thread::spawn(move || {
// Read the file.
let response = minreq::get(format!(
- "http://localhost:{IP_PORT}/vsc/fs/{connection_id}/{}/{hw}",
+ "http://localhost:{}/vsc/fs/{connection_id}/{}/{hw}",
+ *IP_PORT,
test_dir_thread.to_slash().unwrap(),
))
.send()
@@ -1275,7 +1581,8 @@ async fn test_vscode_ide_websocket4b() {
id: INITIAL_CLIENT_MESSAGE_ID,
message: EditorMessageContents::CurrentFile(
format!(
- "http://localhost:{IP_PORT}/vsc/fs/{connection_id}/{}",
+ "http://localhost:{}/vsc/fs/{connection_id}/{}",
+ *IP_PORT,
file_path.to_slash().unwrap()
),
None,
@@ -1318,7 +1625,8 @@ async fn test_vscode_ide_websocket4b() {
let join_handle = thread::spawn(move || {
// Read the file.
let response = minreq::get(format!(
- "http://localhost:{IP_PORT}/vsc/fs/{connection_id}/{}/{hw}",
+ "http://localhost:{}/vsc/fs/{connection_id}/{}/{hw}",
+ *IP_PORT,
test_dir_thread.to_slash().unwrap(),
))
.send()
@@ -1332,7 +1640,8 @@ async fn test_vscode_ide_websocket4b() {
// Now, request the PDF as a raw file.
let response = minreq::get(format!(
- "http://localhost:{IP_PORT}/vsc/fs/{connection_id}/{}/{hw}?raw",
+ "http://localhost:{}/vsc/fs/{connection_id}/{}/{hw}?raw",
+ *IP_PORT,
test_dir_thread.to_slash().unwrap(),
))
.send()
@@ -1471,7 +1780,8 @@ async fn test_vscode_ide_websocket10() {
id: INITIAL_CLIENT_MESSAGE_ID,
message: EditorMessageContents::CurrentFile(
format!(
- "http://localhost:{IP_PORT}/vsc/fs/{connection_id}/{}",
+ "http://localhost:{}/vsc/fs/{connection_id}/{}",
+ *IP_PORT,
file_path.to_slash().unwrap()
),
None,
@@ -1508,7 +1818,8 @@ async fn test_vscode_ide_websocket10() {
let join_handle = thread::spawn(move || {
assert_eq!(
minreq::get(format!(
- "http://localhost:{IP_PORT}/vsc/fs/{connection_id}/{}/test.py",
+ "http://localhost:{}/vsc/fs/{connection_id}/{}/test.py",
+ *IP_PORT,
test_dir_thread.to_slash().unwrap(),
))
.send()
@@ -1552,7 +1863,7 @@ async fn test_vscode_ide_websocket10() {
to: 1,
indent: String::new(),
delimiter: "#".to_string(),
- contents: r#" See See See See ,
+ // The websocket which receives the response.
+ ws_to: &mut WebSocketStream,
+ // The id of the message this responds to.
+ id: f64,
+) {
+ let result = EditorMessage {
+ id,
+ message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)),
+ };
+ send_message(ws_from, &result).await;
+ assert_eq!(read_message(ws_to).await, result);
+}
+
+/// Verify that source from the IDE which can't be translated produces one error
+/// for the Client and leaves it on the last source which could be: the next
+/// update which translates is a diff against that source, and an edit from the
+/// Client doesn't replace the IDE's newer source.
+#[actix_web::test]
+async fn test_vscode_ide_websocket13() {
+ let connection_id = "test-connection-id13";
+ let (temp_dir, test_dir, mut ws_ide, mut ws_client) = prep_test!(connection_id).await;
+ open_client(&mut ws_ide).await;
+
+ // A lexer directive naming an unknown lexer can't be translated. Obscure
+ // this, so that this file can be successfully lexed by the CodeChat Editor.
+ let untranslatable = concat!("# CodeChat Editor ", "lexer: nope\ncode\n# most");
+ let translation_error = "unknown lexer nope".to_string();
+
+ // Set the current file, so subsequent `Update` messages can be translated.
+ let file_path = test_dir.join("test.py");
+ let file_path_str = file_path.to_str().unwrap().to_string();
+ send_message(
+ &mut ws_client,
+ &EditorMessage {
+ id: INITIAL_CLIENT_MESSAGE_ID,
+ message: EditorMessageContents::CurrentFile(
+ format!(
+ "http://localhost:{}/vsc/fs/{connection_id}/{}",
+ *IP_PORT,
+ file_path.to_slash().unwrap(),
+ ),
+ None,
+ ),
+ },
+ )
+ .await;
+ let em = read_message(&mut ws_ide).await;
+ cast!(
+ em.message,
+ EditorMessageContents::CurrentFile,
+ file_name,
+ is_text
+ );
+ send_and_check_result(&mut ws_ide, &mut ws_client, INITIAL_CLIENT_MESSAGE_ID).await;
+
+ // Give the Client its first translation.
+ let mut ide_id = INITIAL_IDE_MESSAGE_ID;
+ send_ide_update(&mut ws_ide, ide_id, &file_path_str, "# more", 0.0).await;
+ let em = read_message(&mut ws_client).await;
+ cast!(em.message, EditorMessageContents::Update);
+ send_and_check_result(&mut ws_client, &mut ws_ide, ide_id).await;
+
+ // The Client, but not the IDE, learns of an update which can't be
+ // translated.
+ let ide_success = |id| EditorMessage {
+ id,
+ message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)),
+ };
+ let client_error = |id| EditorMessage {
+ id,
+ message: EditorMessageContents::Result(Err(ResultErrTypes::CannotTranslateSource(
+ translation_error.clone(),
+ ))),
+ };
+ ide_id += MESSAGE_ID_INCREMENT;
+ send_ide_update(&mut ws_ide, ide_id, &file_path_str, untranslatable, 1.0).await;
+ assert_eq!(read_message(&mut ws_ide).await, ide_success(ide_id));
+ assert_eq!(read_message(&mut ws_client).await, client_error(ide_id));
+
+ // The same error isn't reported to the Client again. If it were, then the
+ // next read from `ws_client` below would fail, since it would return this
+ // error.
+ ide_id += MESSAGE_ID_INCREMENT;
+ send_ide_update(&mut ws_ide, ide_id, &file_path_str, untranslatable, 1.5).await;
+ assert_eq!(read_message(&mut ws_ide).await, ide_success(ide_id));
+
+ // The next update which translates is a diff against the Client's version.
+ ide_id += MESSAGE_ID_INCREMENT;
+ send_ide_update(
+ &mut ws_ide,
+ ide_id,
+ &file_path_str,
+ "# more\ncode\n# most",
+ 2.0,
+ )
+ .await;
+ assert_eq!(
+ read_message(&mut ws_client).await,
+ EditorMessage {
+ id: ide_id,
+ message: EditorMessageContents::Update(UpdateMessageContents {
+ file_path: file_path_str.clone(),
+ cursor_position: None,
+ scroll_position: None,
+ is_re_translation: false,
+ contents: Some(CodeChatForWeb {
+ metadata: SourceFileMetadata {
+ mode: "python".to_string(),
+ },
+ source: CodeMirrorDiffable::Diff(CodeMirrorDiff {
+ doc: vec![StringDiff {
+ from: 1,
+ to: None,
+ insert: "code\n\n".to_string()
+ }],
+ doc_blocks: vec.
//
-// Relax a few pedantic Clippy lints for test code only; see the `[lints.clippy]`
-// section in `Cargo.toml` for the full-crate (production) lint config.
+// Relax a few pedantic Clippy lints for test code only; see the
+// `[workspace.lints.clippy]` section in `Cargo.toml` for the full-crate
+// (production) lint config.
#![cfg_attr(
test,
allow(
diff --git a/server/src/processing.rs b/server/src/processing.rs
index 0edbcefc..6c02415d 100644
--- a/server/src/processing.rs
+++ b/server/src/processing.rs
@@ -59,7 +59,7 @@ use htmd::{
options::{BrStyle, LinkStyle, TranslationMode},
};
use html5ever::{
- Attribute, LocalName, Namespace, ParseOpts, QualName, ns, parse_document, serialize,
+ Attribute, LocalName, Namespace, ParseOpts, QualName, ns, parse_fragment, serialize,
serialize::{SerializeOpts, TraversalScope},
tendril::TendrilSink,
tree_builder::TreeBuilderOpts,
@@ -268,10 +268,10 @@ static DOC_BLOCK_SEPARATOR_BROKEN_FENCE: LazyLock