Skip to content

Commit 5a84d5f

Browse files
committed
feat(parser): add support for internal ## File: header
This commit introduces support for a new internal header format within code blocks: `## File: path/to/file.ext`. Similar to the existing `// File:` format, this header can appear on the first line of a fenced code block to specify the file path for the block's content. The header line itself is excluded from the generated file content. This format provides an alternative way to define file paths internally, especially useful in contexts where `//` comments might not be standard (e.g., YAML, plain text). Key features: - Recognizes `## File: path` and `## File: \`path with spaces\``. - Extracts the path, handling optional backticks. - Excludes the header line from the output file content. - Applies path format validation (rejects empty or invalid paths like `//`). - Ignores the header if it doesn't appear on the *first* line of the block. Added corresponding integration tests in `tests/parser/create_internal.rs` to verify the new format and its edge cases.
1 parent d229b70 commit 5a84d5f

File tree

2 files changed

+117
-4
lines changed

2 files changed

+117
-4
lines changed

src/parser/pass1/internal_header.rs

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
33
use crate::core_types::Action; // Import Action
44
use crate::errors::ParseError;
5-
use crate::parser::helpers::{is_likely_comment, is_likely_string};
5+
use crate::parser::helpers::{ensure_trailing_newline, is_likely_comment, is_likely_string};
66
use crate::parser::internal_comment::extract_path_from_internal_comment;
7+
use crate::parser::path_utils::validate_path_format;
78
// Import the specific handler function and the context struct (if needed, though it's internal to the handler)
89
use crate::parser::pass1::internal_comment_handler;
910
use crate::parser::pass1::internal_standard_handler;
@@ -31,7 +32,7 @@ pub(crate) fn handle_internal_header(
3132
let stripped_first_line = first_line.trim();
3233
let header_original_pos = block_content_start + parse_offset; // Calculate original pos
3334

34-
// Check for // File: path or // path
35+
// --- Check for // File: path or // path ---
3536
if let Some((path, include_header)) =
3637
extract_path_from_internal_comment(first_line, stripped_first_line)
3738
{
@@ -52,7 +53,59 @@ pub(crate) fn handle_internal_header(
5253
);
5354
}
5455

55-
// Check for **Action:** or ## Action:
56+
// --- Check for ## File: path or ## File: `path` ---
57+
// This is a new internal header format, similar to // File: but using ##
58+
if stripped_first_line.starts_with("## File:") {
59+
let content_after_prefix = stripped_first_line.strip_prefix("## File:").unwrap().trim();
60+
let path = if content_after_prefix.len() > 1
61+
&& content_after_prefix.starts_with('`')
62+
&& content_after_prefix.ends_with('`')
63+
{
64+
content_after_prefix[1..content_after_prefix.len() - 1]
65+
.trim()
66+
.to_string()
67+
} else {
68+
content_after_prefix.to_string()
69+
};
70+
71+
if path.is_empty() {
72+
eprintln!(
73+
"Warning: Internal header '## File:' at original pos {} found with empty path. Skipping.",
74+
header_original_pos
75+
);
76+
return Ok(None);
77+
}
78+
79+
if validate_path_format(&path).is_err() {
80+
eprintln!(
81+
"Warning: Invalid path format in internal header '{}' at original pos {}. Skipping.",
82+
stripped_first_line, header_original_pos
83+
);
84+
return Ok(None);
85+
}
86+
87+
println!(
88+
" Found internal header: '{}' (Excluded from output)",
89+
stripped_first_line
90+
);
91+
processed_header_starts.insert(header_original_pos); // Mark header as processed
92+
93+
let mut final_content = rest_content.to_string();
94+
ensure_trailing_newline(&mut final_content);
95+
96+
let action = Action {
97+
action_type: crate::core_types::ActionType::Create,
98+
path,
99+
content: Some(final_content),
100+
original_pos: 0, // Set later in pass1 mod
101+
};
102+
println!(" -> Added CREATE action for '{}'", action.path);
103+
// Return the action and the block content start position (relative)
104+
return Ok(Some((action, block_content_start)));
105+
}
106+
107+
// --- Check for **Action:** or ## Action: (but not ## File:) ---
108+
// This handles standard markdown headers like **File:** if they appear internally
56109
if let Some(caps) = HEADER_REGEX.captures(first_line) {
57110
// Apply heuristics *before* trying to extract path/action
58111
if is_likely_comment(stripped_first_line) || is_likely_string(stripped_first_line) {
@@ -62,6 +115,7 @@ pub(crate) fn handle_internal_header(
62115
);
63116
return Ok(None); // Ignore, do not mark as processed
64117
}
118+
// Call the standard handler for these formats
65119
return internal_standard_handler::handle_internal_standard_header(
66120
caps,
67121
rest_content,
@@ -72,5 +126,6 @@ pub(crate) fn handle_internal_header(
72126
);
73127
}
74128

129+
// No internal header format matched on the first line
75130
Ok(None)
76131
}

tests/parser/create_internal.rs

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! Tests for parsing internal 'Create' headers (// File:, //path).
1+
//! Tests for parsing internal 'Create' headers (// File:, //path, ## File:).
22
33
use super::common::*; // Use helper from common.rs
44
use strux::core_types::ActionType;
@@ -42,3 +42,61 @@ fn test_parse_internal_comment_backticks_path_excluded() {
4242
Some("Content here.\n"), // Header line excluded
4343
);
4444
}
45+
46+
// --- NEW TESTS for ## File: internal header ---
47+
48+
#[test]
49+
fn test_parse_internal_hash_file_header_excluded() {
50+
let md = "\n```yaml\n## File: config/settings.yaml\nkey: value\nlist:\n - item1\n```\n";
51+
let actions = parse_markdown(md).expect("Parsing failed");
52+
assert_eq!(actions.len(), 1);
53+
assert_action(
54+
actions.first(),
55+
ActionType::Create,
56+
"config/settings.yaml",
57+
Some("key: value\nlist:\n - item1\n"), // Header line excluded
58+
);
59+
}
60+
61+
#[test]
62+
fn test_parse_internal_hash_file_header_backticks_excluded() {
63+
let md = "\n```\n## File: `docs/My Document.md`\n# Title\nSome text.\n```\n";
64+
let actions = parse_markdown(md).expect("Parsing failed");
65+
assert_eq!(actions.len(), 1);
66+
assert_action(
67+
actions.first(),
68+
ActionType::Create,
69+
"docs/My Document.md",
70+
Some("# Title\nSome text.\n"), // Header line excluded
71+
);
72+
}
73+
74+
#[test]
75+
fn test_parse_internal_hash_file_header_not_first_line_ignored() {
76+
let md = "\n```\nSome initial content.\n## File: should_be_ignored.txt\nMore content.\n```\n";
77+
let actions = parse_markdown(md).expect("Parsing failed");
78+
assert!(
79+
actions.is_empty(),
80+
"Internal ## File: header not on the first line should be ignored"
81+
);
82+
}
83+
84+
#[test]
85+
fn test_parse_internal_hash_file_header_invalid_path_ignored() {
86+
let md = "\n```\n## File: invalid//path.log\nLog data\n```\n";
87+
let actions = parse_markdown(md).expect("Parsing failed");
88+
assert!(
89+
actions.is_empty(),
90+
"Internal ## File: header with invalid path should be ignored"
91+
);
92+
}
93+
94+
#[test]
95+
fn test_parse_internal_hash_file_header_empty_path_ignored() {
96+
let md = "\n```\n## File: \nContent\n```\n";
97+
let actions = parse_markdown(md).expect("Parsing failed");
98+
assert!(
99+
actions.is_empty(),
100+
"Internal ## File: header with empty path should be ignored"
101+
);
102+
}

0 commit comments

Comments
 (0)