use std::collections::HashMap;
use mago_syntax::ast::class_like::member::ClassLikeMember;
use mago_syntax::ast::class_like::method::MethodBody;
use mago_syntax::ast::*;
use tower_lsp::lsp_types::*;
use crate::Backend;
use crate::code_actions::CodeActionData;
use crate::code_actions::make_code_action_data;
use crate::completion::use_edit::{analyze_use_block, build_use_edit, use_import_conflicts};
use crate::parser::with_parsed_program;
use crate::util::{
byte_range_to_lsp_range, offset_to_position, ranges_overlap, short_name as util_short_name,
strip_fqn_prefix, strip_trailing_modifiers,
};
const CHECKED_EXCEPTION_ID: &str = "missingType.checkedException";
impl Backend {
pub(crate) fn collect_add_throws_actions(
&self,
uri: &str,
content: &str,
params: &CodeActionParams,
out: &mut Vec<CodeActionOrCommand>,
) {
let phpstan_diags: Vec<Diagnostic> = {
let cache = self.phpstan_last_diags.lock();
cache.get(uri).cloned().unwrap_or_default()
};
let file_use_map: HashMap<String, String> = self.file_use_map(uri);
let file_namespace: Option<String> = self.namespace_map.read().get(uri).cloned().flatten();
for diag in &phpstan_diags {
if !ranges_overlap(&diag.range, ¶ms.range) {
continue;
}
let identifier = match &diag.code {
Some(NumberOrString::String(s)) => s.as_str(),
_ => continue,
};
if identifier != CHECKED_EXCEPTION_ID {
continue;
}
let exception_fqn = match extract_exception_fqn(&diag.message) {
Some(fqn) => fqn,
None => continue,
};
let short_name = crate::util::short_name(&exception_fqn);
let already_imported = file_use_map.iter().any(|(alias, fqn)| {
alias.eq_ignore_ascii_case(short_name) && fqn.eq_ignore_ascii_case(&exception_fqn)
});
let same_namespace = match &file_namespace {
Some(ns) => {
let ns_prefix = format!("{}\\", ns);
let stripped = exception_fqn.strip_prefix(&ns_prefix);
stripped.is_some_and(|rest| !rest.contains('\\'))
}
None => !exception_fqn.contains('\\'),
};
let needs_import = !already_imported && !same_namespace;
if needs_import && use_import_conflicts(&exception_fqn, &file_use_map) {
continue;
}
let diag_line = diag.range.start.line as usize;
let docblock_info = match find_enclosing_docblock(content, diag_line) {
Some(info) => info,
None => continue,
};
if docblock_already_has_throws(&docblock_info, short_name) {
continue;
}
let title = format!("Add @throws {}", short_name);
let extra = serde_json::json!({
"diagnostic_message": diag.message,
"diagnostic_line": diag.range.start.line,
"diagnostic_code": CHECKED_EXCEPTION_ID,
});
let data = make_code_action_data("phpstan.addThrows", uri, ¶ms.range, extra);
out.push(CodeActionOrCommand::CodeAction(CodeAction {
title,
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![diag.clone()]),
edit: None,
command: None,
is_preferred: Some(true),
disabled: None,
data: Some(data),
}));
}
}
pub(crate) fn resolve_add_throws(
&self,
data: &CodeActionData,
content: &str,
) -> Option<WorkspaceEdit> {
let uri = &data.uri;
let diagnostic_message = data.extra.get("diagnostic_message")?.as_str()?;
let diagnostic_line = data.extra.get("diagnostic_line")?.as_u64()? as usize;
let exception_fqn = extract_exception_fqn(diagnostic_message)?;
let short_name = crate::util::short_name(&exception_fqn);
let file_use_map: HashMap<String, String> = self.file_use_map(uri);
let file_namespace: Option<String> = self.namespace_map.read().get(uri).cloned().flatten();
let already_imported = file_use_map.iter().any(|(alias, fqn)| {
alias.eq_ignore_ascii_case(short_name) && fqn.eq_ignore_ascii_case(&exception_fqn)
});
let same_namespace = match &file_namespace {
Some(ns) => {
let ns_prefix = format!("{}\\", ns);
let stripped = exception_fqn.strip_prefix(&ns_prefix);
stripped.is_some_and(|rest| !rest.contains('\\'))
}
None => !exception_fqn.contains('\\'),
};
let needs_import = !already_imported && !same_namespace;
let docblock_info = find_enclosing_docblock(content, diagnostic_line)?;
let mut edits = Vec::new();
let throws_edit = build_throws_edit(content, &docblock_info, short_name);
edits.push(throws_edit);
if needs_import {
let use_block = analyze_use_block(content);
if let Some(import_edits) = build_use_edit(&exception_fqn, &use_block, &file_namespace)
{
edits.extend(import_edits);
}
}
let doc_uri: Url = uri.parse().ok()?;
let mut changes = HashMap::new();
changes.insert(doc_uri, edits);
Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
})
}
}
pub(crate) fn extract_exception_fqn(message: &str) -> Option<String> {
let marker = "throws checked exception ";
let start = message.find(marker)? + marker.len();
let rest = &message[start..];
let end = rest.find(" but")?;
let fqn = rest[..end].trim();
if fqn.is_empty() {
return None;
}
Some(strip_fqn_prefix(fqn).to_string())
}
struct DocblockInfo {
has_docblock: bool,
start: usize,
end: usize,
text: String,
indent: String,
sig_line_start: usize,
}
fn find_enclosing_docblock(content: &str, diag_line: usize) -> Option<DocblockInfo> {
let lines: Vec<&str> = content.lines().collect();
if diag_line >= lines.len() {
return None;
}
let mut diag_byte_offset = 0usize;
for (i, line) in lines.iter().enumerate() {
if i == diag_line {
break;
}
diag_byte_offset += line.len() + 1; }
let search_area = content.get(..diag_byte_offset)?;
let mut brace_depth = 0i32;
let mut func_open_brace: Option<usize> = None;
for (i, ch) in search_area.char_indices().rev() {
match ch {
'}' => brace_depth += 1,
'{' => {
brace_depth -= 1;
if brace_depth < 0 {
func_open_brace = Some(i);
break;
}
}
_ => {}
}
}
let brace_pos = func_open_brace?;
let before_brace = content.get(..brace_pos)?;
let mut sig_start = before_brace.len().saturating_sub(2000);
while sig_start > 0 && !before_brace.is_char_boundary(sig_start) {
sig_start -= 1;
}
let sig_region = &before_brace[sig_start..];
let func_kw_rel = sig_region.rfind("function")?;
let func_kw_pos = sig_start + func_kw_rel;
let before_func = content.get(..func_kw_pos)?;
let trimmed = before_func.trim_end();
let after_mods = strip_trailing_modifiers(trimmed);
let sig_line_byte_start = {
let mods_end_pos = after_mods.len();
let first_token_pos = if mods_end_pos < func_kw_pos {
content[mods_end_pos..func_kw_pos]
.find(|c: char| !c.is_whitespace())
.map(|offset| mods_end_pos + offset)
.unwrap_or(func_kw_pos)
} else {
func_kw_pos
};
content[..first_token_pos]
.rfind('\n')
.map(|p| p + 1)
.unwrap_or(0)
};
let indent: String = {
let line = &content[sig_line_byte_start..];
line.chars()
.take_while(|c| c.is_whitespace() && *c != '\n')
.collect()
};
let after_mods_trimmed = after_mods.trim_end();
if after_mods_trimmed.ends_with("*/") {
let doc_end_pos = after_mods_trimmed.len();
if let Some(rel_open) = after_mods_trimmed.rfind("/**") {
let doc_start_pos = rel_open;
let text = after_mods_trimmed[doc_start_pos..doc_end_pos].to_string();
return Some(DocblockInfo {
has_docblock: true,
start: doc_start_pos,
end: doc_end_pos,
text,
indent,
sig_line_start: sig_line_byte_start,
});
}
}
Some(DocblockInfo {
has_docblock: false,
start: 0,
end: 0,
text: String::new(),
indent,
sig_line_start: sig_line_byte_start,
})
}
fn docblock_already_has_throws(info: &DocblockInfo, short_name: &str) -> bool {
if !info.has_docblock {
return false;
}
let parsed = match crate::docblock::parser::parse_docblock_for_tags(&info.text) {
Some(parsed) => parsed,
None => return false,
};
let lower = short_name.to_lowercase();
for tag in parsed.tags_by_kind(mago_docblock::document::TagKind::Throws) {
let rest = tag.description.trim();
if let Some(type_name) = rest.split_whitespace().next() {
let short = util_short_name(type_name);
if short.eq_ignore_ascii_case(&lower) {
return true;
}
}
}
false
}
fn build_throws_edit(content: &str, info: &DocblockInfo, short_name: &str) -> TextEdit {
if info.has_docblock {
insert_throws_into_existing_docblock(content, info, short_name)
} else {
create_docblock_with_throws(content, info, short_name)
}
}
fn insert_throws_into_existing_docblock(
content: &str,
info: &DocblockInfo,
short_name: &str,
) -> TextEdit {
let doc = &info.text;
let indent = &info.indent;
let close_pos = match doc.rfind("*/") {
Some(p) => p,
None => {
return create_docblock_with_throws(content, info, short_name);
}
};
let open_to_close = &doc[3..close_pos];
let is_single_line = !open_to_close.contains('\n');
if is_single_line {
let inner = open_to_close.trim();
let mut new_doc = format!("{}/**\n", indent);
if !inner.is_empty() {
new_doc.push_str(&format!("{} * {}\n", indent, inner));
new_doc.push_str(&format!("{} *\n", indent));
}
new_doc.push_str(&format!("{} * @throws {}\n", indent, short_name));
new_doc.push_str(&format!("{} */", indent));
return TextEdit {
range: byte_range_to_lsp_range(content, info.start, info.end),
new_text: new_doc,
};
}
let before_close = doc[..close_pos].trim_end();
let last_line = before_close.lines().last().unwrap_or("");
let last_trimmed = last_line.trim().trim_start_matches('*').trim();
let needs_separator = !last_trimmed.is_empty()
&& !last_trimmed.starts_with("@throws")
&& last_trimmed.starts_with('@');
let mut insert_text = String::new();
if needs_separator {
insert_text.push_str(&format!("{} *\n", indent));
}
insert_text.push_str(&format!("{} * @throws {}\n", indent, short_name));
let close_line_start = doc[..close_pos].rfind('\n').map(|p| p + 1).unwrap_or(0);
let actual_insert_offset = info.start + close_line_start;
let lsp_pos = offset_to_position(content, actual_insert_offset);
TextEdit {
range: Range {
start: lsp_pos,
end: lsp_pos,
},
new_text: insert_text,
}
}
fn create_docblock_with_throws(_content: &str, info: &DocblockInfo, short_name: &str) -> TextEdit {
let indent = &info.indent;
let new_doc = format!(
"{}/**\n{} * @throws {}\n{} */\n",
indent, indent, short_name, indent
);
let lsp_pos = offset_to_position(_content, info.sig_line_start);
TextEdit {
range: Range {
start: lsp_pos,
end: lsp_pos,
},
new_text: new_doc,
}
}
pub(crate) fn find_enclosing_function_line_range(
content: &str,
diag_line: usize,
) -> Option<(usize, usize)> {
let mut cursor_offset = 0usize;
let mut found = false;
for (i, line) in content.lines().enumerate() {
if i == diag_line {
found = true;
break;
}
cursor_offset += line.len() + 1;
}
if !found {
return None;
}
let cursor_offset = cursor_offset as u32;
with_parsed_program(
content,
"find_enclosing_function_line_range",
|program, content| {
find_function_range_in_statements(&program.statements, cursor_offset, content)
},
)
}
fn find_function_range_in_statements(
statements: &Sequence<'_, Statement<'_>>,
cursor: u32,
content: &str,
) -> Option<(usize, usize)> {
for stmt in statements.iter() {
match stmt {
Statement::Namespace(ns) => {
if let Some(range) =
find_function_range_in_statements(ns.statements(), cursor, content)
{
return Some(range);
}
}
Statement::Function(func) => {
let open = func.body.left_brace.start.offset;
let close = func.body.right_brace.start.offset;
if cursor >= open && cursor <= close {
let open_line = offset_to_position(content, open as usize).line as usize;
let close_line = offset_to_position(content, close as usize).line as usize;
return Some((open_line, close_line));
}
}
Statement::Class(class) => {
if let Some(range) =
find_method_range_in_members(class.members.iter(), cursor, content)
{
return Some(range);
}
}
Statement::Interface(iface) => {
if let Some(range) =
find_method_range_in_members(iface.members.iter(), cursor, content)
{
return Some(range);
}
}
Statement::Trait(tr) => {
if let Some(range) =
find_method_range_in_members(tr.members.iter(), cursor, content)
{
return Some(range);
}
}
Statement::Enum(en) => {
if let Some(range) =
find_method_range_in_members(en.members.iter(), cursor, content)
{
return Some(range);
}
}
_ => {}
}
}
None
}
fn find_method_range_in_members<'a>(
members: impl Iterator<Item = &'a ClassLikeMember<'a>>,
cursor: u32,
content: &str,
) -> Option<(usize, usize)> {
for member in members {
if let ClassLikeMember::Method(method) = member
&& let MethodBody::Concrete(block) = &method.body
{
let open = block.left_brace.start.offset;
let close = block.right_brace.start.offset;
if cursor >= open && cursor <= close {
let open_line = offset_to_position(content, open as usize).line as usize;
let close_line = offset_to_position(content, close as usize).line as usize;
return Some((open_line, close_line));
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_fqn_from_method_message() {
let msg = "Method App\\Controllers\\Foo::bar() throws checked exception App\\Exceptions\\BarException but it's missing from the PHPDoc @throws tag.";
let fqn = extract_exception_fqn(msg).unwrap();
assert_eq!(fqn, "App\\Exceptions\\BarException");
}
#[test]
fn extracts_fqn_from_function_message() {
let msg = "Function doStuff() throws checked exception App\\Exceptions\\StuffException but it's missing from the PHPDoc @throws tag.";
let fqn = extract_exception_fqn(msg).unwrap();
assert_eq!(fqn, "App\\Exceptions\\StuffException");
}
#[test]
fn extracts_fqn_from_property_hook_message() {
let msg = "Get hook for property App\\Foo::$bar throws checked exception App\\Exceptions\\PropException but it's missing from the PHPDoc @throws tag.";
let fqn = extract_exception_fqn(msg).unwrap();
assert_eq!(fqn, "App\\Exceptions\\PropException");
}
#[test]
fn strips_leading_backslash() {
let msg = "Method Foo::bar() throws checked exception \\Global\\SomeException but it's missing from the PHPDoc @throws tag.";
let fqn = extract_exception_fqn(msg).unwrap();
assert_eq!(fqn, "Global\\SomeException");
}
#[test]
fn returns_none_for_unrelated_message() {
let msg = "Some other PHPStan error about something.";
assert!(extract_exception_fqn(msg).is_none());
}
#[test]
fn strips_public_static() {
assert_eq!(strip_trailing_modifiers(" public static").trim(), "");
}
#[test]
fn strips_protected() {
assert_eq!(
strip_trailing_modifiers("some code\n protected").trim(),
"some code"
);
}
#[test]
fn does_not_strip_partial_keyword() {
assert_eq!(strip_trailing_modifiers("mypublic"), "mypublic");
}
#[test]
fn detects_existing_throws() {
let info = DocblockInfo {
has_docblock: true,
start: 0,
end: 0,
text: "/**\n * @throws FooException\n */".to_string(),
indent: " ".to_string(),
sig_line_start: 0,
};
assert!(docblock_already_has_throws(&info, "FooException"));
}
#[test]
fn detects_existing_throws_case_insensitive() {
let info = DocblockInfo {
has_docblock: true,
start: 0,
end: 0,
text: "/**\n * @throws fooexception\n */".to_string(),
indent: " ".to_string(),
sig_line_start: 0,
};
assert!(docblock_already_has_throws(&info, "FooException"));
}
#[test]
fn detects_fqn_throws() {
let info = DocblockInfo {
has_docblock: true,
start: 0,
end: 0,
text: "/**\n * @throws \\App\\Exceptions\\FooException\n */".to_string(),
indent: " ".to_string(),
sig_line_start: 0,
};
assert!(docblock_already_has_throws(&info, "FooException"));
}
#[test]
fn no_existing_throws() {
let info = DocblockInfo {
has_docblock: true,
start: 0,
end: 0,
text: "/**\n * @param string $a\n */".to_string(),
indent: " ".to_string(),
sig_line_start: 0,
};
assert!(!docblock_already_has_throws(&info, "FooException"));
}
#[test]
fn no_docblock_no_throws() {
let info = DocblockInfo {
has_docblock: false,
start: 0,
end: 0,
text: String::new(),
indent: " ".to_string(),
sig_line_start: 0,
};
assert!(!docblock_already_has_throws(&info, "FooException"));
}
#[test]
fn finds_existing_docblock() {
let php = "<?php\nclass Foo {\n /**\n * Summary.\n */\n public function bar(): void {\n throw new \\RuntimeException();\n }\n}\n";
let info = find_enclosing_docblock(php, 6).unwrap();
assert!(info.has_docblock);
assert!(info.text.contains("Summary."));
assert_eq!(info.indent, " ");
}
#[test]
fn finds_no_docblock() {
let php = "<?php\nclass Foo {\n public function bar(): void {\n throw new \\RuntimeException();\n }\n}\n";
let info = find_enclosing_docblock(php, 3).unwrap();
assert!(!info.has_docblock);
assert_eq!(info.indent, " ");
}
#[test]
fn inserts_throws_into_multiline_docblock() {
let php = "<?php\nclass Foo {\n /**\n * Summary.\n */\n public function bar(): void {\n throw new \\RuntimeException();\n }\n}\n";
let info = find_enclosing_docblock(php, 6).unwrap();
let edit = build_throws_edit(php, &info, "RuntimeException");
assert!(
edit.new_text.contains("@throws RuntimeException"),
"edit should contain @throws: {:?}",
edit.new_text
);
}
#[test]
fn multiline_insert_does_not_double_indent_closing_tag() {
let php = "<?php\nclass Foo {\n /**\n * Summary.\n */\n public function bar(): void {\n throw new \\RuntimeException();\n }\n}\n";
let info = find_enclosing_docblock(php, 6).unwrap();
let edit = build_throws_edit(php, &info, "RuntimeException");
let start = offset_to_position(php, 0); let _ = start;
let insert_offset = {
let mut off = 0usize;
for (i, line) in php.lines().enumerate() {
if i == edit.range.start.line as usize {
off += edit.range.start.character as usize;
break;
}
off += line.len() + 1;
}
off
};
let end_offset = {
let mut off = 0usize;
for (i, line) in php.lines().enumerate() {
if i == edit.range.end.line as usize {
off += edit.range.end.character as usize;
break;
}
off += line.len() + 1;
}
off
};
let mut result = String::new();
result.push_str(&php[..insert_offset]);
result.push_str(&edit.new_text);
result.push_str(&php[end_offset..]);
let close_line = result.lines().find(|l| l.trim() == "*/").unwrap();
assert_eq!(
close_line, " */",
"closing */ should be aligned with the docblock (5 chars: 4 spaces + space before */).\nFull result:\n{}",
result
);
}
#[test]
fn inserts_throws_into_single_line_docblock() {
let php = "<?php\nclass Foo {\n /** Summary. */\n public function bar(): void {\n throw new \\RuntimeException();\n }\n}\n";
let info = find_enclosing_docblock(php, 4).unwrap();
let edit = build_throws_edit(php, &info, "RuntimeException");
assert!(
edit.new_text.contains("@throws RuntimeException"),
"edit should contain @throws: {:?}",
edit.new_text
);
assert!(
edit.new_text.contains("Summary."),
"edit should preserve summary: {:?}",
edit.new_text
);
}
#[test]
fn creates_new_docblock_when_none_exists() {
let php = "<?php\nclass Foo {\n public function bar(): void {\n throw new \\RuntimeException();\n }\n}\n";
let info = find_enclosing_docblock(php, 3).unwrap();
let edit = build_throws_edit(php, &info, "RuntimeException");
assert!(
edit.new_text.contains("/**"),
"should create a docblock: {:?}",
edit.new_text
);
assert!(
edit.new_text.contains("@throws RuntimeException"),
"should contain @throws: {:?}",
edit.new_text
);
assert_eq!(
edit.new_text, " /**\n * @throws RuntimeException\n */\n",
"new docblock should be aligned with the method"
);
}
#[test]
fn appends_after_existing_throws() {
let php = "<?php\nclass Foo {\n /**\n * Summary.\n *\n * @throws FooException\n */\n public function bar(): void {\n throw new \\RuntimeException();\n }\n}\n";
let info = find_enclosing_docblock(php, 8).unwrap();
assert!(!docblock_already_has_throws(&info, "RuntimeException"));
let edit = build_throws_edit(php, &info, "RuntimeException");
assert!(
edit.new_text.contains("@throws RuntimeException"),
"should add @throws: {:?}",
edit.new_text
);
}
#[test]
fn inserts_throws_after_return() {
let php = "<?php\nclass Foo {\n /**\n * @param string $a\n *\n * @return string\n */\n public function bar(string $a): string {\n throw new \\RuntimeException();\n }\n}\n";
let info = find_enclosing_docblock(php, 8).unwrap();
let edit = build_throws_edit(php, &info, "RuntimeException");
assert!(
edit.new_text.contains("@throws RuntimeException"),
"should add @throws: {:?}",
edit.new_text
);
}
#[test]
fn inserts_throws_after_return_aligned() {
let php = concat!(
"<?php\nclass Foo {\n",
" /**\n",
" * @return Response\n",
" */\n",
" public function clientside(): Response {\n",
" throw new \\RuntimeException();\n",
" }\n",
"}\n",
);
let info = find_enclosing_docblock(php, 6).unwrap();
let edit = build_throws_edit(php, &info, "RuntimeException");
let insert_offset = {
let mut off = 0usize;
for (i, line) in php.lines().enumerate() {
if i == edit.range.start.line as usize {
off += edit.range.start.character as usize;
break;
}
off += line.len() + 1;
}
off
};
let end_offset = {
let mut off = 0usize;
for (i, line) in php.lines().enumerate() {
if i == edit.range.end.line as usize {
off += edit.range.end.character as usize;
break;
}
off += line.len() + 1;
}
off
};
let mut result = String::new();
result.push_str(&php[..insert_offset]);
result.push_str(&edit.new_text);
result.push_str(&php[end_offset..]);
let expected = concat!(
"<?php\nclass Foo {\n",
" /**\n",
" * @return Response\n",
" *\n",
" * @throws RuntimeException\n",
" */\n",
" public function clientside(): Response {\n",
" throw new \\RuntimeException();\n",
" }\n",
"}\n",
);
assert_eq!(
result, expected,
"inserted @throws must not double-indent the closing */.\nGot:\n{}",
result
);
}
#[test]
fn works_with_standalone_function() {
let php = "<?php\n/**\n * Does stuff.\n */\nfunction doStuff(): void {\n throw new \\RuntimeException();\n}\n";
let info = find_enclosing_docblock(php, 5).unwrap();
assert!(info.has_docblock);
let edit = build_throws_edit(php, &info, "RuntimeException");
assert!(
edit.new_text.contains("@throws RuntimeException"),
"should add @throws: {:?}",
edit.new_text
);
}
#[test]
fn function_line_range_simple_method() {
let php = concat!(
"<?php\n", "class Foo {\n", " public function bar(): void {\n", " throw new \\RuntimeException();\n", " throw new \\RuntimeException();\n", " }\n", "}\n", );
let (start, end) = find_enclosing_function_line_range(php, 3).unwrap();
assert_eq!(start, 2, "opening brace line");
assert_eq!(end, 5, "closing brace line");
let (start2, end2) = find_enclosing_function_line_range(php, 4).unwrap();
assert_eq!((start2, end2), (start, end));
}
#[test]
fn function_line_range_standalone_function() {
let php = concat!(
"<?php\n", "function doStuff(): void {\n", " throw new \\RuntimeException();\n", "}\n", );
let (start, end) = find_enclosing_function_line_range(php, 2).unwrap();
assert_eq!(start, 1);
assert_eq!(end, 3);
}
#[test]
fn function_line_range_nested_braces() {
let php = concat!(
"<?php\n", "class Foo {\n", " public function bar(): void {\n", " if (true) {\n", " throw new \\RuntimeException();\n", " }\n", " throw new \\RuntimeException();\n", " }\n", "}\n", );
let (start, end) = find_enclosing_function_line_range(php, 4).unwrap();
assert_eq!(start, 2, "opening brace line");
assert_eq!(end, 7, "closing brace line");
let (start2, end2) = find_enclosing_function_line_range(php, 6).unwrap();
assert_eq!((start2, end2), (start, end));
}
#[test]
fn function_line_range_two_methods() {
let php = concat!(
"<?php\n", "class Foo {\n", " public function first(): void {\n", " throw new \\RuntimeException();\n", " }\n", " public function second(): void {\n", " throw new \\RuntimeException();\n", " }\n", "}\n", );
let (s1, e1) = find_enclosing_function_line_range(php, 3).unwrap();
assert_eq!((s1, e1), (2, 4));
let (s2, e2) = find_enclosing_function_line_range(php, 6).unwrap();
assert_eq!((s2, e2), (5, 7));
}
#[test]
fn function_line_range_returns_none_for_out_of_range() {
let php = "<?php\necho 'hi';\n";
assert!(find_enclosing_function_line_range(php, 99).is_none());
}
#[test]
fn function_line_range_returns_none_outside_function() {
let php = concat!(
"<?php\n", "echo 'hi';\n", );
assert!(find_enclosing_function_line_range(php, 1).is_none());
}
}